@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 ll.* calls, so less runtime code ships with your script. 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

The plugin translates string methods to LSL ll.* functions or Luau string.* 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.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 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.

Plain-text literals. For indexOf, includes, and the startsWith fallback, the 1, true plain-text arguments to string.find are only emitted when the needle could contain Luau pattern characters.

startsWith / endsWith literals. With a string-literal needle, both compile to a string.sub comparison. Empty literal folds to true. A non-literal startsWith(x) falls back to string.find(str, x, 1, true) == 1, and a non-literal endsWith(x) falls back to TSTL's default endsWith helper.

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

The plugin translates TypeScript bitwise operators to bit32 library calls, since SLua has no 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 (&=, |=, ^=, <<=, >>=, >>>=) desugar to the same bit32 calls.

btest optimization

Comparisons of a bitwise AND against zero fold into 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 adjusts index arguments and return values from the @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) becomes 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 anything else stays as it 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

The plugin collapses a zero-parameter arrow function that only calls another zero-parameter function into 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 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 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 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 | 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 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 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.

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

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")
end

Optimized output:

if x == nil then
    print("missing")
end

Only 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] = 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