SuperAPI AI Directory

luau-expert

Coding & development

View repository

Updated 10-15 08:42

About this skill

Luau Language

Write modern, strictly-typed Luau. Source: luau.org.

For deep dives see:

Further reading: references/type-functions-api.md (analysis-time types library), references/type-patterns.md (branded/recursive types, overloads, read-only tables).


Integration Policy

This skill describes preferred patterns; it does not authorize rewriting existing code. Apply rules to new code and code the user explicitly asks to refactor. Match the codebase's existing style (casing, indentation, quote style, module shape) before applying skill defaults. Don't weaken stricter files; don't introduce tooling the project doesn't already use; surface conflicts once and default to the existing pattern.

Full rules: ../../shared/integration-policy.md.


Principles

  • Strictly typed. New files use --!strict. Migrate legacy code; never weaken a file that was strict.
  • Bugs caught at analysis time beat runtime asserts. A typed parameter is worth more than assert(typeof(x) == "number").
  • Simple over clever. Small functions, obvious intent.
  • Prefer immutability. Mutating shared state under yields breaks everything; with React-style libraries it's non-negotiable.
  • Type public APIs fully; skip trivial locals. Annotate anything other modules can call. Don't annotate local n = item.attack(p) — inference handles it.

Mode Header

--!strict      -- default for new files; flags anything that *might* fail at runtime
--!nonstrict   -- legacy migration; infers any when type unknown
--!nocheck     -- generated/third-party only

Project-wide: .luaurc with { "languageMode": "strict" }.


Modern Syntax

-- String interpolation (not string.format / not concat)
print(`Player {name} scored {score} points`)

-- if-then-else expression (not `a and b or c`)
local label = if score >= 100 then "Winner" else "Try again"

-- Generalized iteration (not pairs/ipairs)
for k, v in t do ... end

-- Compound assignments
count += 1
path ..= "/suffix"

-- Continue
for _, item in items do
    if not item.active then continue end
    process(item)
end

-- Floor division
local q = 7 // 2

-- Const bindings (binding immutable; value still mutable)
const MAX_PLAYERS = 16

-- Number separators
local big = 1_000_000

-- Function attributes
@native
local function hotPath(x: number): number
    return x * x + x
end

ipairs only when you specifically need stop-at-first-nil semantics on a sparse array.


Idioms

Early Return / Continue

Guard clauses over pyramids — when the function logically can't keep going, return early.

local function dealDamage(humanoid: Humanoid, damage: number)
    if damage <= 0 then return end
    humanoid.Health -= damage
end

Explicit nil Checks

Most "truthy" checks really mean "not nil". Write what you mean:

if x.Parent ~= nil then ...   -- ✅
if x.Parent then ...          -- ❌ ambiguous

Two exceptions where truthy is fine: if-expressions (if x then a else b reads better than the inverted form) and or defaults.

or for Defaults; Never x and y or z

soundClone.Volume = volume or 0.5

-- Never simulate ternary with and/or — silently wrong when middle value is falsy
local goldAmount = if gamemode == "arena" then nil else 100   -- ✅
-- local goldAmount = gamemode == "arena" and nil or 100      -- ❌ returns 100 even on arena

Don't Hide Builtins

local insert = table.insert    -- ❌
table.insert(items, sword)     -- ✅

No Call-Sugar

call("string")    -- ✅
call({ ... })     -- ✅
call "string"     -- ❌
call { ... }      -- ❌ (StyLua reformats)

Async Suffix on Yielding Functions

local function fetchPlayerDataAsync(userId: number): PlayerData
    -- yields here
end

React (and similar render libraries) does not expect yields — a re-render that hits a yielding call silently corrupts component state. Naming yielding functions makes it visible at the call site.

pcall Method-Shorthand

pcall(part.Destroy, part)                       -- ❌ reads as nonsense
pcall(function() part:Destroy() end)            -- ✅
pcall(saveMoney, player, 100)                   -- ✅ function-shorthand reads naturally

Requires

  • No dynamic requires. require(Modules.X) keeps types; require(child) in a loop erases them.
  • One alphabetical block. No sectioning by package vs local. Use Luau LSP auto-require + StyLua sort_requires (covered in roblox-tooling).

Type System (essentials)

-- Optional
type Maybe<T> = T?              -- T | nil

-- Tagged union (preferred for results)
type Result<T> = { ok: true, value: T } | { ok: false, err: string }

-- Indexed maps where misses are expected
local playerPoints: { [Player]: number? } = {}    -- ✅ checker forces nil-handling
-- { [Player]: number }                            -- ❌ hides bugs on missing keys

-- Optional argument typing — ? at outer position
local function f(default: (boolean | () -> boolean)?) end    -- ✅
-- local function f(default: boolean? | () -> boolean) end   -- ❌

unknown for untrusted input; narrow before use. any for nothing — always reach for unknown first.

Full tour in references/type-system.md.

nil ≠ Nothing

local function returnsNothing() end          -- zero values
local function returnsNil() return nil end   -- one value: nil

print(returnsNothing())   -- (blank)
print(returnsNil())       -- nil

Keep return shape consistent across branches. If a function returns T?, the no-value branch must return nil, not bare return.

String Enums + exhaustiveMatch

The only enum form to use:

type Color = "red" | "green" | "blue"

local function exhaustiveMatch(value: never): never
    error(`Unknown value in exhaustive match: {value}`)
end

local function setColor(color: Color)
    if color == "red" then ...
    elseif color == "green" then ...
    elseif color == "blue" then ...
    else exhaustiveMatch(color)   -- type error if a branch is missed
    end
end

No makeEnum({...}) libraries, no Color = { red = "red" :: Color } wrappers. They give nothing string unions don't.

See examples/exhaustive-match.luau.


Asserts

  • Always pass an error message. Bare assert(cond) produces useless assertion failed!.
  • Constant messages only. assert evaluates its message every call. For formatting use if not cond then error(\...`) end`.
  • Don't assert(typeof(x) == ...) to patch types. If your code is strictly typed, that line never trips except via untyped callers — fix the upstream type instead.
  • At trust boundaries (RemoteEvents, JSON, plugin input) type the input as unknown and narrow:
HurtMe.OnServerEvent:Connect(function(player, damage: unknown)
    if typeof(damage) ~= "number" or damage < 0 then return end
    -- damage: number from here
    dealDamage(player, damage)
end)

Typing the parameter as number makes the runtime check look statically valid even when an exploiter sends a string. unknown forces the check.

See examples/remote-validation.luau.


Modules

--!strict
local OtherModule = require("./OtherModule")

export type Config = { timeout: number, retries: number }

const DEFAULT_CONFIG: Config = { timeout = 30, retries = 3 }

local MyModule = {}

function MyModule.connect(cfg: Config?): ()
    local c = cfg or DEFAULT_CONFIG
    -- ...
end

return table.freeze(MyModule)
  • Always assign and return a named local. No return function() ... end — breaks Ctrl-Shift-F across web/rg.
  • One file per utility. No TableUtil mega-modules; flatten.luau, reverse.luau separately. Luau LSP auto-requires bare names. Project-structure rationale lives in roblox-systems.
  • table.freeze the returned module table to catch accidental mutations.
  • Cyclic deps: cast the imported module to any to break the cycle — and document why.

Shallow Copy, Not Deep

For immutable updates, clone only the path you're touching:

items = table.clone(items)
items[1] = table.clone(items[1])
items[1].durability -= 10

Deep copy is always wasted work under immutability — unrelated subtrees keep their identity, which is exactly what == checks across renders want.

See examples/module-pattern.luau.


OOP — Default to Free Functions

Plain data + free functions over classes. Metatables fight the type checker, hurt grep, and surprise readers.

type Slide = { length: number }
type Video = { slides: { Slide } }

-- videoLength.luau
local function videoLength(video: Video): number
    local total = 0
    for _, slide in video.slides do
        total += slide.length
    end
    return total
end

return videoLength

Aside from __index and __tostring (and __mode for the rare weak table), avoid every metamethod. No __call, no __add, no operator overloads — they don't autocomplete and they fight inference.

When a class is genuinely warranted, see references/typed-class-recipe.md.


Verification Rules

  • Don't assume Lua APIs exist in Luau. Several were removed or relocated:
    • setfenv / getfenv — removed.
    • unpack (global) — moved to table.unpack.
    • loadstring — removed; use loadstring-equivalent only via loadstring if explicitly enabled, otherwise unavailable.
    • bit32 — present, but consider native bitwise operators where possible.
    • goto / labels — not in Luau.
  • Don't invent syntax. If you're unsure whether a feature (e.g. const, @native, type functions, default generics) exists or is stable, check luau.org before using it. State the uncertainty in your reply rather than guessing.
  • Stable concepts beat brittle signatures. If you can express the rule as "use the type system to prevent the bug" rather than "this exact stdlib function takes these exact arguments", do that — Luau's stdlib evolves; the typing principles don't.
  • Roblox APIs are out of scope here. For RemoteEvent, DataStore, Instance, services, etc., defer to the roblox-dev skill. This skill covers the language only.

Anti-Patterns

❌ Don't ✅ Do
pairs(t) / ipairs(t) for k, v in t do
string.format(...) `interpolated {value}`
a and b or c if a then b else c
if x.Parent then if x.Parent ~= nil then
local insert = table.insert Inline table.insert
call "x" / call { ... } call("x") / call({ ... })
Reassignable constants const MAX = 100
Globals local / const
any unknown + narrowing
Trivial local annotations Let inference work
Untyped public function Annotate params + return
assert(typeof(x) == "T") to patch types Fix the upstream type
assert(cond) no message / formatted message Constant message; if … then error(…) for formatted
pcall(part.Destroy, part) pcall(function() part:Destroy() end)
{ [K]: V } when keys may be missing { [K]: V? }
Bare return from a T? function Explicit return nil
for _, m in c:GetChildren() do require(m) Static require(c.X)
Sectioned require blocks One alphabetical block
Yielding fn missing Async suffix Suffix Async
makeEnum({"red","green"}) type Color = "red" | "green" + exhaustiveMatch
__call / __add / operator metamethods Plain functions
Anonymous module return local foo = ...; return foo
TableUtil mega-modules One function per file
Deep copy for immutable update Clone only the touched path
math.floor(a / b) a // b
Mutating module table table.freeze the return
Pyramid nesting Early returns + continue

Examples