@gwigz/slua

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

TypeScriptLua 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

TypeScriptLua 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:

TypeScriptLua 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 the fromIndex to 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

TypeScriptLua 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.

TypeScriptLua output
a & bbit32.band(a, b)
a | bbit32.bor(a, b)
a ^ bbit32.bxor(a, b)
a << bbit32.lshift(a, b)
a >> bbit32.arshift(a, b)
a >>> bbit32.rshift(a, b)
~abit32.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:

TypeScriptLua output
(a & b) !== 0bit32.btest(a, b)
(a & b) === 0not 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:

TypeScriptLua 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)
  • @indexArg parameters get + 1 (constant-folded for literals)
  • @indexReturn wraps 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:

TypeScriptLua 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 //:

TypeScriptLua 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 ~~x and x | 0 do not map cleanly to Luau. ~~x emits bit32.bnot(bit32.bnot(x)) and x | 0 emits bit32.bor(x, 0), neither of which preserves correct semantics for negative numbers (the bit32 library operates on unsigned 32-bit integers). Use math.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:

TypeScriptLua 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 functionCompiles 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 | undefined

Multi-value flags like OBJECT_PERMS spread into the tuple:

const [base, owner, group, everyone, nextOwner] = ll.GetObjectDetails(id, [OBJECT_PERMS])
// all: number | undefined

The 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 Vector

For 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.

tsconfig.json
{
  "tstl": {
    "luaPlugins": [{ "name": "@gwigz/slua-tstl-plugin", "optimize": true }],
  },
}
tsconfig.json
{
  "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_0

compoundAssignment

Rewrites self-reassignment arithmetic to Luau compound assignment operators.

TypeScriptLua output
x = x + nx += n
x = x - 1x -= 1
x = x .. sx ..= s

floorMultiply

Reorders Math.floor((a / b) * c) to use the floor division operator, avoiding a math.floor call.

TypeScriptLua 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.

TypeScriptLua output
s.indexOf(x) >= 0string.find(s, x, 1, true)
s.indexOf(x) !== -1string.find(s, x, 1, true)
s.indexOf(x) === -1not string.find(s, x, 1, true)
arr.indexOf(x) >= 0table.find(arr, x)
arr.indexOf(x) === -1not 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.b

Optimized output:

local _r0 = fn()
local a, b = _r0.a, _r0.b

inlineLocals

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 = 5

Optimized output:

local x = 5

numericConcat

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: " .. count

Non-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
end

Optimized output:

function respondPoll(extraHtml)
    extraHtml = extraHtml or ""
end

Safe 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_MASK

Default 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] = undefined

To 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 = remaining

Prefer 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> = {}

On this page