Luau tips

Get past the Luau type checker traps that cost the most time, each with the exact error it prints and the fix.

These come from Luau's type checker (Studio's old solver), not from ShotScript, so plain Luau hits them too. The examples use these types:

public type Kind = "Tool" | "Food"
public type Def = { Id : string, Kind : Kind }
public type Slot = Def | false

local function useKind(k : Kind) end
local function useDef(d : Def) end

1. String literals widen to string

A literal like "Tool" keeps its literal type only where the checker already expects Kind. Stored without a type, it becomes plain string, and string doesn't fit Kind. The error is Expected this to be '"Food" | "Tool"', but got 'string'; none of the union options are compatible.

Where it happens:

local kinds = { "Tool", "Food" }
useKind(kinds[1])                         -- an array literal

local raw = { Kind = "Tool" }
useDef({ Id = "x", Kind = raw.Kind })     -- a field read from an untyped table

local DEFS = { { Id = "a", Kind = "Tool" } }
useDef(DEFS[1])                           -- a table of records without a type

The fix: give the local its type, so the literals are checked against it when they are written.

local kinds : {Kind} = { "Tool", "Food" }
useKind(kinds[1])

local raw : { Kind : Kind } = { Kind = "Tool" }
useDef({ Id = "x", Kind = raw.Kind })

local DEFS : {Def} = { { Id = "a", Kind = "Tool" } }
useDef(DEFS[1])

Loop variables widen even when the array is typed. for _, k in all, for _, k in ipairs(all) and a loop over a literal array all give k the type string:

local all : {Kind} = { "Tool", "Food" }
for _, k in all do
    useKind(k)        -- Expected this to be '"Food" | "Tool"' but got 'string | string'
end

Annotate the loop variable instead (a typed local inside the loop doesn't help, k is already string):

for _, k : Kind in all do
    useKind(k)
end

A field of a record keeps its type (for _, d in defs do useKind(d.Kind) end is fine), and so does a return: a function declared : Kind may return if b then "Tool" else "Food".

2. X | false doesn't narrow

local s = slots[1]      -- slots : {Slot}, Slot = Def | false
if s then
    print(s.Id)         -- Key 'Id' is missing from 'true' in the type 'Def | true'
end

if s then, if s ~= false then and if not s then return end all leave a boolean in the type. Fixes:

if typeof(s) == "table" then
    print(s.Id)
end

if s then
    local d = s :: Def
    print(d.Id)
end

local d : Def? = slots[1] or nil  -- turn false into nil once; Def? narrows normally
if d then
    print(d.Id)
end

Use T? instead of T | false wherever the value isn't an array slot. (An array can't hold nil, which is why an array of slots marks an empty one with false.)

3. Indexing {[Union] : T} with a literal

local prices : {[Kind] : number} = { Tool = 1, Food = 2 }
print(prices.Tool)        -- Key 'Tool' not found in table '{ ["Food" | "Tool"]: number }'
print(prices["Food"])     -- Expected this to be '"Food" | "Tool"', but got 'string'

Fix: give the key its type.

print(prices["Tool" :: Kind])

local k : Kind = "Food"
print(prices[k])

4. t[k] or {} makes a union

local counts : {[string] : {[string] : number}} = {}
local c = counts[key] or {}
c.wins = (c.wins or 0) + 1
-- Key 'wins' is missing from '{|  |}' in the type '{ [string]: number } | {|  |}'

local list = byKind[kind] or {}  -- byKind : {[string] : {Def}}
for _, d in list do  -- Cannot call a value of type {Def} in union: {Def} | {|  |}
end

The empty {} has its own type, so the local is "the real table or an empty one". Fix: annotate the local.

local c : {[string] : number} = counts[key] or {}
local list : {Def} = byKind[kind] or {}

5. Smaller ones

  • An untyped parameter is any: "Unknown type used in + operation; consider adding a type annotation to 'x'". Type every parameter.
  • local self = init() is any, so nothing in the constructor is checked. Write local self : Shape = init().
  • Casting with :: any switches checking off for that value; everything it touches goes unchecked.