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 LSL function calls for better performance and smaller output. 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
String methods are translated to LSL ll.* functions or Luau string.* stdlib 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.find(str, x, 1, true) == 1 |
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.
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
TypeScript bitwise operators are automatically translated to bit32 library calls, since SLua does not support 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 (&=, |=, ^=, <<=, >>=, >>>=) are also supported and desugar to the same bit32 calls.
btest optimization
Comparisons of a bitwise AND against zero are automatically optimized to 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 automatically adjusts index arguments and return values based on @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) is translated to 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 a non-division argument is left as-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
Zero-parameter arrow functions that just call another zero-parameter function are collapsed to 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 provides fluent builder functions that compile to these flat lists at build time with zero runtime cost.
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 are accessed via dedicated methods like .typeBox(), .typeSphere(), etc.:
$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 is derived from the LSL constant by stripping the set prefix and converting to camelCase. For example, PRIM_COLOR becomes .color(), PSYS_SRC_BURST_RATE becomes .srcBurstRate(), and CAMERA_FOCUS_LOCKED becomes .focusLocked().
Builder chains are entirely compile-time, no objects, arrays, or closures are allocated at runtime. The plugin walks the method chain AST and emits the flat Lua table directly.
Options object overload
$castRay and $httpRequest support an options-object overload, which compiles to the same flat parameter list but can be used 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)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 fully 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 simple 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 all possible return types.
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. Automatically disabled for files with more than one .filter() call, where the shared helper results in 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.
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.
Keeping output small
Some TypeScript patterns pull in large TSTL runtime helpers. Here are recommendations for keeping output lean.
Enforce with oxlint
Install @gwigz/slua-oxlint-config to catch these patterns automatically at lint time. See the
README for setup instructions.
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.
Instead, type your records to allow undefined and assign undefined (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> = {}