Transpiler Plugin
How @gwigz/slua-tstl-plugin transforms TypeScript patterns to optimized SLua output
The @gwigz/slua-tstl-plugin replaces TSTL lualib helpers with native Luau stdlib and ll.* calls, so less runtime code ships with your script. It also handles index adjustment, casing fixes, and optional output optimizations.
Transforms
JSON
| TypeScript | Lua output |
|---|---|
JSON.stringify(val) | lljson.encode(val) |
JSON.parse(str) | lljson.decode(str) |
For SL-typed JSON (preserving vector/quaternion/uuid), use lljson.slencode/lljson.sldecode directly.
Base64
| TypeScript | Lua output |
|---|---|
btoa(str) | llbase64.encode(str) |
atob(str) | llbase64.decode(str) |
String methods
The plugin translates string methods to LSL ll.* functions or Luau string.* calls:
| TypeScript | Lua output |
|---|---|
str.toUpperCase() | ll.ToUpper(str) |
str.toLowerCase() | ll.ToLower(str) |
str.trim() | ll.StringTrim(str, STRING_TRIM) |
str.trimStart() | ll.StringTrim(str, STRING_TRIM_HEAD) |
str.trimEnd() | ll.StringTrim(str, STRING_TRIM_TAIL) |
str.indexOf(x) | (string.find(str, x, 1, true) or 0) - 1 |
str.indexOf(x, from) | (string.find(str, x, from + 1, true) or 0) - 1 |
str.includes(x) | string.find(str, x, 1, true) ~= nil |
str.startsWith(x) | string.sub(str, 1, #x) == x |
str.endsWith(x) | string.sub(str, -#x) == x |
str.split(sep) | string.split(str, sep) |
str.repeat(n) | string.rep(str, n) |
str.substring(start) | string.sub(str, start + 1) |
str.substring(s, e) | string.sub(str, s + 1, e) |
str.replace(a, b) | ll.ReplaceSubString(str, a, b, 1) |
str.replaceAll(a, b) | ll.ReplaceSubString(str, a, b, 0) |
str.indexOf(x, fromIndex)adjusts thefromIndexto 1-based (constant-folded for literals).str.startsWith(x, position)with a second argument falls through to TSTL's default handling. Similarly,str.split()with no separator is not transformed.
Plain-text literals. For
indexOf,includes, and thestartsWithfallback, the1, trueplain-text arguments tostring.findare only emitted when the needle could contain Luau pattern characters.
startsWith/endsWithliterals. With a string-literal needle, both compile to astring.subcomparison. Empty literal folds totrue. A non-literalstartsWith(x)falls back tostring.find(str, x, 1, true) == 1, and a non-literalendsWith(x)falls back to TSTL's defaultendsWithhelper.
Array methods
| TypeScript | Lua output |
|---|---|
arr.includes(val) | table.find(arr, val) ~= nil |
arr.indexOf(val) | (table.find(arr, val) or 0) - 1 |
arr.indexOf(val, fromIndex)with a second argument falls through to TSTL's default handling.
Bitwise operators
The plugin translates TypeScript bitwise operators to bit32 library calls, since SLua has no native Lua bitwise operators.
| TypeScript | Lua output |
|---|---|
a & b | bit32.band(a, b) |
a | b | bit32.bor(a, b) |
a ^ b | bit32.bxor(a, b) |
a << b | bit32.lshift(a, b) |
a >> b | bit32.arshift(a, b) |
a >>> b | bit32.rshift(a, b) |
~a | bit32.bnot(a) |
Compound assignments (&=, |=, ^=, <<=, >>=, >>>=) desugar to the same bit32 calls.
btest optimization
Comparisons of a bitwise AND against zero fold into bit32.btest:
| TypeScript | Lua output |
|---|---|
(a & b) !== 0 | bit32.btest(a, b) |
(a & b) === 0 | not bit32.btest(a, b) |
This works with !=, ==, and with the zero on either side (0 !== (a & b)).
Index adjustment
SLua's ll.* functions use 1-based indexing (Lua convention), but TypeScript uses 0-based. The plugin adjusts index arguments and return values from the @indexArg and @indexReturn JSDoc tags in the type definitions:
| TypeScript | Lua output |
|---|---|
ll.GetSubString("hello", 0, 2) | ll.GetSubString("hello", 1, 3) |
ll.GetSubString("hello", i, j) | ll.GetSubString("hello", i + 1, j + 1) |
ll.ListFindList(a, b) | ____tmp = ll.ListFindList(a, b); ____tmp and (____tmp - 1) |
@indexArgparameters get+ 1(constant-folded for literals)@indexReturnwraps the result in a nil-safe____tmp and (____tmp - 1)expression- Functions without these tags (e.g.
ll.Say) are left unchanged
Array concat self-assignment
When an array is reassigned to itself with additional elements appended, the plugin emits table.extend (SLua's in-place append) instead of TSTL's __TS__ArrayConcat which allocates a new table:
| TypeScript | Lua output |
|---|---|
arr = arr.concat(b) | table.extend(arr, b) |
arr = arr.concat(b, c) | table.extend(table.extend(arr, b), c) |
arr = [...arr, ...b] | table.extend(arr, b) |
arr = [...arr, ...b, ...c] | table.extend(table.extend(arr, b), c) |
This optimization only applies when:
- The expression is a statement (not
const result = arr.concat(b)) - The LHS is a simple identifier matching the receiver/first spread
- All concat arguments / spread expressions are array-typed
Floor division
Math.floor(a / b) becomes the native Luau floor division operator //:
| TypeScript | Lua output |
|---|---|
Math.floor(a / b) | a // b |
This only applies when the argument is directly a / expression. Math.floor(x) with anything else stays as it is.
JavaScript integer truncation idioms
~~xandx | 0do not map cleanly to Luau.~~xemitsbit32.bnot(bit32.bnot(x))andx | 0emitsbit32.bor(x, 0), neither of which preserves correct semantics for negative numbers (thebit32library operates on unsigned 32-bit integers). Usemath.floor(x)for floor truncation instead.
Passthrough arrow closures
The plugin collapses a zero-parameter arrow function that only calls another zero-parameter function into a direct function reference:
| TypeScript | Lua output |
|---|---|
LLTimers.once(1, () => patchNext()) | LLTimers:once(1, patchNext) |
LLEvents.on("on_rez", () => refreshUrl()) | LLEvents:on("on_rez", refreshUrl) |
This applies when:
- The arrow has zero parameters
- The body is a single call with zero arguments
- The callee is a simple identifier (not a method call)
- The callee's type signature has zero parameters
Builder chains
Many LSL functions accept flat parameter lists of [CONSTANT, ...args, CONSTANT, ...args] pairs. The plugin ships builder functions that compile to those flat lists at build time.
Prim parameters
$setPrimParams(LINK_THIS)
.color(0, new Vector(1, 0, 0), 1)
.glow(0, 0.5)
.fullbright(ALL_SIDES, true)ll.SetLinkPrimitiveParamsFast(LINK_THIS, {
PRIM_COLOR, 0, vector.create(1, 0, 0), 1,
PRIM_GLOW, 0, 0.5,
PRIM_FULLBRIGHT, ALL_SIDES, true
})Use .link() with a callback to scope parameters to a different link number. The callback flattens into a PRIM_LINK_TARGET entry:
$setPrimParams(LINK_THIS)
.color(0, new Vector(1, 0, 0), 1)
.link(2, (link) => link.color(0, new Vector(0, 1, 0), 0.8))ll.SetLinkPrimitiveParamsFast(LINK_THIS, {
PRIM_COLOR, 0, vector.create(1, 0, 0), 1,
PRIM_LINK_TARGET, 2,
PRIM_COLOR, 0, vector.create(0, 1, 0), 0.8
})Shape variants get their own methods, .typeBox(), .typeSphere(), and so on:
$setPrimParams(LINK_THIS).typeBox(
PRIM_HOLE_DEFAULT,
new Vector(0, 1, 0),
0.5,
ZERO_VECTOR,
ZERO_VECTOR,
ZERO_VECTOR,
)ll.SetLinkPrimitiveParamsFast(LINK_THIS, {
PRIM_TYPE, PRIM_TYPE_BOX, PRIM_HOLE_DEFAULT, vector.create(0, 1, 0), 0.5,
vector.create(0, 0, 0), vector.create(0, 0, 0), vector.create(0, 0, 0)
})Available builders
| Builder function | Compiles to |
|---|---|
$setPrimParams(link) | ll.SetLinkPrimitiveParamsFast(link, {...}) |
$particleSystem() | ll.ParticleSystem({...}) |
$linkParticleSystem(link) | ll.LinkParticleSystem(link, {...}) |
$setCameraParams() | ll.SetCameraParams({...}) |
$httpRequest(url, opts) | ll.HTTPRequest(url, {...}, body) |
$castRay(start, end, opts) | ll.CastRay(start, end, {...}) |
$createCharacter() | ll.CreateCharacter({...}) |
$updateCharacter() | ll.UpdateCharacter({...}) |
$rezObjectWithParams(item) | ll.RezObjectWithParams(item, {...}) |
$setGltfOverrides(link, face) | ll.SetLinkGLTFOverrides(link, face, {...}) |
Each builder method name comes from the LSL constant, minus the set prefix and camelCased. PRIM_COLOR becomes .color(), PSYS_SRC_BURST_RATE becomes .srcBurstRate(), and CAMERA_FOCUS_LOCKED becomes .focusLocked().
Builder chains are entirely compile-time. The plugin walks the method chain AST and emits the flat Lua table directly, so no objects, arrays, or closures reach the runtime.
Options object overload
$castRay and $httpRequest also take an options object, which compiles to the same flat parameter list but works as an expression:
const result = $castRay(start, end, {
rejectTypes: RC_REJECT_AGENTS,
maxHits: 4,
dataFlags: RC_GET_NORMAL,
})local result = ll.CastRay(start, end_, {
RC_REJECT_TYPES, RC_REJECT_AGENTS,
RC_MAX_HITS, 4,
RC_DATA_FLAGS, RC_GET_NORMAL
})For $httpRequest, the body is included in the options object (defaults to ""). Method defaults to "GET":
const id = $httpRequest("https://example.com", {
method: "POST",
mimetype: "application/json",
body: payload,
})local id = ll.HTTPRequest("https://example.com", {
HTTP_METHOD, "POST",
HTTP_MIMETYPE, "application/json"
}, payload)Tuple-valued params like customHeader are written as inline array literals
and spread into the flat list:
const id = $httpRequest("https://example.com", {
customHeader: ["X-Key", "abc"],
})local id = ll.HTTPRequest("https://example.com", {
HTTP_CUSTOM_HEADER, "X-Key", "abc",
HTTP_METHOD, "GET"
}, "")Typed getter returns
Functions like ll.GetObjectDetails, ll.GetParcelDetails, ll.GetPrimitiveParams, ll.GetEnvironment, ll.GetPrimMediaParams, ll.GetLinkMedia, and ll.ParcelMediaQuery accept an array of flag constants and return a corresponding list of values. The type definitions map each flag to its return type, so destructuring gives you typed results:
const [name, pos] = ll.GetObjectDetails(id, [OBJECT_NAME, OBJECT_POS])
// name: string | undefined
// pos: Vector | undefinedMulti-value flags like OBJECT_PERMS spread into the tuple:
const [base, owner, group, everyone, nextOwner] = ll.GetObjectDetails(id, [OBJECT_PERMS])
// all: number | undefinedThe return type is MapObjectDetails<T> | [], either the mapped tuple or an empty table when the target object doesn't exist. A truthiness check handles both cases:
const [pos] = ll.GetObjectDetails(id, [OBJECT_POS])
if (!pos) {
return
}
// pos is narrowed to VectorFor ll.GetPrimitiveParams, flags that require a face argument are validated at the type level too:
const [color, alpha] = ll.GetPrimitiveParams([PRIM_COLOR, ALL_SIDES])
// color: Vector | undefined
// alpha: number | undefined
const [name, texture, repeats, offsets, rot] = ll.GetPrimitiveParams([PRIM_NAME, PRIM_TEXTURE, 0])
// name: string | undefined
// texture: string | undefined, repeats: Vector | undefined, ...Some functions always return a fixed-shape tuple: ll.GetExperienceDetails returns ExperienceDetails, ll.GetPhysicsMaterial returns PhysicsMaterial, and DetectedEvent.getDamage() returns DamageDetails.
When the flags array isn't a const tuple (e.g. a dynamic ObjectDetailFlag[]), the return falls back to a union array of every possible return type.
Optimizations
Pass optimize: true to enable all optimizations, or pass an object to pick individual flags. All flags default to false when not specified.
{
"tstl": {
"luaPlugins": [{ "name": "@gwigz/slua-tstl-plugin", "optimize": true }],
},
}{
"tstl": {
"luaPlugins": [
{
"name": "@gwigz/slua-tstl-plugin",
"optimize": {
"compoundAssignment": true,
"shortenTemps": true,
"inlineLocals": true,
},
},
],
},
}filter
Inlines arr.filter(cb) as an ipairs loop instead of pulling in __TS__ArrayFilter. Disabled for files with more than one .filter() call, where the shared helper makes for a smaller script.
const result = arr.filter((x) => x > 0)local function ____opt_fn_0(x)
return x > 0
end
local ____opt_0 = {}
for _, ____opt_v_0 in ipairs(arr) do
if ____opt_fn_0(____opt_v_0) then
____opt_0[#____opt_0 + 1] = ____opt_v_0
end
end
local result = ____opt_0compoundAssignment
Rewrites self-reassignment arithmetic to Luau compound assignment operators.
| TypeScript | Lua output |
|---|---|
x = x + n | x += n |
x = x - 1 | x -= 1 |
x = x .. s | x ..= s |
floorMultiply
Reorders Math.floor((a / b) * c) to use the floor division operator, avoiding a math.floor call.
| TypeScript | Lua output |
|---|---|
Math.floor((used / limit) * 100) | used * 100 // limit |
Plain Math.floor(a / b) is always optimized to a // b regardless of this flag.
indexOf
Emits bare string.find / table.find for indexOf presence checks instead of the full (find or 0) - 1 pattern.
| TypeScript | Lua output |
|---|---|
s.indexOf(x) >= 0 | string.find(s, x, 1, true) |
s.indexOf(x) !== -1 | string.find(s, x, 1, true) |
s.indexOf(x) === -1 | not string.find(s, x, 1, true) |
arr.indexOf(x) >= 0 | table.find(arr, x) |
arr.indexOf(x) === -1 | not table.find(arr, x) |
Bare indexOf calls without a comparison still emit (find or 0) - 1 to retain 0-index style responses. As with the other string.find paths, the 1, true arguments are dropped when x is a magic-free string literal.
shortenTemps
Shortens TSTL's destructuring temp names and collapses consecutive field accesses into multi-assignment.
const { a, b } = fn()Default output:
local ____fn_result_0 = fn()
local a = ____fn_result_0.a
local b = ____fn_result_0.bOptimized output:
local _r0 = fn()
local a, b = _r0.a, _r0.binlineLocals
Merges forward-declared local x with its first x = value assignment when there are no references to x in between.
Default output:
local x
x = 5Optimized output:
local x = 5numericConcat
Strips tostring() from number-typed (and string-typed) template literal interpolations, since Luau's .. operator handles numeric concatenation natively.
// count is number
const msg = `items: ${count}`Default output:
local msg = "items: " .. tostring(count)Optimized output:
local msg = "items: " .. countNon-numeric types (booleans, any, etc.) still get wrapped in tostring().
defaultParams
Collapses default-parameter nil-checks into a single or expression.
function respondPoll(extraHtml = "") {
// ...
}Default output:
function respondPoll(extraHtml)
if extraHtml == nil then
extraHtml = ""
end
endOptimized output:
function respondPoll(extraHtml)
extraHtml = extraHtml or ""
endSafe for string and number defaults (both truthy in Lua). Not applied to false defaults.
foldBitwise
Folds compile-time constant bitwise expressions into a single numeric literal, eliminating nested bit32.* calls for static flag combinations. An inline comment preserves the original variable names.
const flags = PSYS_PART_EMISSIVE_MASK | PSYS_PART_INTERP_COLOR_MASK | PSYS_PART_INTERP_SCALE_MASKDefault output:
local flags = bit32.bor(bit32.bor(PSYS_PART_EMISSIVE_MASK, PSYS_PART_INTERP_COLOR_MASK), PSYS_PART_INTERP_SCALE_MASK)Optimized output:
local flags = 259 --[[ PSYS_PART_EMISSIVE_MASK | PSYS_PART_INTERP_COLOR_MASK | PSYS_PART_INTERP_SCALE_MASK ]]Constants must have numeric literal types (e.g. declare const MASK: 256, not declare const MASK: number) for the type checker to resolve their values. All SLua integer constants are emitted with literal types by default.
All bitwise operators are supported (|, &, ^, ~, <<, >>, >>>). Expressions with any non-constant operands fall through to the normal bit32.* calls.
simplifyNilChecks
Rewrites not (x ~= nil) to x == nil. TSTL emits the negated form when double-negated null checks survive the AST (e.g. !(x !== null) in source). The rewrite drops a not and a pair of parens, and it reads like hand-written Lua.
declare const x: string | null
if (!(x !== null)) {
print("missing")
}Default output:
if not (x ~= nil) then
print("missing")
endOptimized output:
if x == nil then
print("missing")
endOnly applies to nil comparisons. not (x ~= 1) and other non-nil inequalities are left alone.
Keeping output small
A few TypeScript patterns pull in large TSTL runtime helpers. These are the ones worth avoiding.
Enforce with oxlint
Install @gwigz/slua-oxlint-config to catch these patterns automatically at lint time. See the
README for setup instructions.
It also flags the truthiness gotcha.
Avoid delete on objects
The delete operator pulls in __TS__Delete, which depends on the entire Error class hierarchy, __TS__Class, __TS__ClassExtends, __TS__New, and __TS__ObjectGetOwnPropertyDescriptors, roughly 150 lines of runtime code.
Type your records to allow undefined and assign undefined instead, which compiles to nil:
// Bad
const cache: Record<string, Data> = {}
delete cache[key]
// Good, compiles to `cache[key] = nil`
const cache: Record<string, Data | undefined> = {}
cache[key] = undefinedTo clear an entire record, use let and reassign instead of iterating with delete:
// Bad
for (const key of Object.keys(cache)) {
delete cache[key]
}
// Good
let cache: Record<string, Data | undefined> = {}
// ...
cache = {}Avoid Array.splice()
splice() pulls in __TS__ArraySplice and __TS__CountVarargs, roughly 75 lines. Rebuild the array instead:
// Bad
for (let i = items.length - 1; i >= 0; i--) {
if (shouldRemove(items[i])) {
items.splice(i, 1)
}
}
// Good, compiles to simple table operations
let items: Item[] = []
const remaining: Item[] = []
for (const item of items) {
if (!shouldRemove(item)) {
remaining.push(item)
}
}
items = remainingPrefer for...in over Object.entries()
Object.entries() pulls in __TS__ObjectEntries. Use for...in which compiles directly to for key in pairs(obj):
// Pulls in __TS__ObjectEntries
for (const [key, value] of Object.entries(obj)) { ... }
// Compiles to `for key in pairs(obj)`, no helpers
for (const key in obj) {
const value = obj[key]
}Avoid Map and Set
TSTL's Map and Set polyfills add roughly 400 lines of runtime. Use plain Record<string, T> and arrays instead:
// Bad, ~400 lines of runtime
const lookup = new Map<string, UUID>()
const seen = new Set<string>()
// Good, plain Lua tables
const lookup: Record<string, UUID | undefined> = {}
const seen: Record<string, boolean> = {}