Strictly typing Luau files
Convert a file to --!strict and make it pass the type checker cleanly. This is mostly
mechanical pattern application — move fast and correctly. The checker is your fast
feedback loop; lean on it instead of guessing.
Triage first — match effort to the file
- Plain util / data module (
local X = {}of functions, or areturn {...}table; nosetmetatable): flip the header, give every function typed params/returns, run single-file analyze. Usually noexport typeblock needed. - Class (
setmetatable({}, ...)+ constructor + methods): the real work — needs theexport typeblock. Follow the patterns below andreferences/conventions.md. - Already
--!strictbut erroring: skip conversion; just resolve the reported errors.
Why it's not find-and-replace
Luau can't infer fields through setmetatable, so strict mode turns that blind spot into
errors. The fix is an explicit export type block enumerating every self field, plus
dot-syntax methods that name self. Flipping the header without these just produces a wall
of errors — supplying the types the checker can't infer is the job.
The verification loop (fast inner loop)
luau-lsp analyze is the same engine as lint:luau, pointed at one file (~2.5s). One-time
setup if sourcemap.json / globalTypes.d.lua are missing at repo root: npm run prelint:luau.
luau-lsp analyze --sourcemap=sourcemap.json --base-luaurc=.luaurc \
--defs=globalTypes.d.lua --flag:LuauSolverV2=false --ignore='**/node_modules/**' \
src/<package>/src/<Realm>/<File>.lua
Clean = only the [INFO] Loading... line. LuauSolverV2=false is required (repo pins the old
solver). Iterate until clean, then run npm run lint:luau once as the final gate — single-file
analyze can't see files that depend on yours, and tightening a type ripples to subclasses and
callers. Triage new downstream errors: pre-existing → leave & flag; your type is genuinely too
tight for the real contract → loosen your type (often T? not T); a small obvious follow-on
→ fix it.
Core patterns
Class with a parent (the common case):
--!strict
local require = require(script.Parent.loader).load(script)
local BaseObject = require("BaseObject")
local MyClass = setmetatable({}, BaseObject)
MyClass.ClassName = "MyClass"
MyClass.__index = MyClass
export type MyClass = typeof(setmetatable(
{} :: {
_serviceBag: ServiceBag.ServiceBag, -- EVERY self field, with its type
_enabled: ValueObject.ValueObject<boolean>,
},
{} :: typeof({ __index = MyClass })
)) & BaseObject.BaseObject -- intersection pulls in inherited _maid, _obj, etc.
function MyClass.new(serviceBag: ServiceBag.ServiceBag): MyClass
local self: MyClass = setmetatable(BaseObject.new() :: any, MyClass)
self._serviceBag = assert(serviceBag, "No serviceBag")
return self
end
Methods use dot syntax with explicit self (colon syntax loses the self type in strict
mode). Callers still write obj:Method(); only the definition changes:
function MyClass.GetEnabled(self: MyClass): boolean
return self._enabled.Value
end
The export type rule — always typeof(setmetatable(...)), never hand-list methods
There is one way to write a class's export type, and it holds for generic, inherited, and
dynamic-__index classes alike:
export type MyClass = typeof(setmetatable(
{} :: { ...only the instance FIELDS... },
{} :: typeof({ __index = MyClass })
)) [& Parent.Parent]
Methods come from typeof({ __index = MyClass }) — never hand-write Method: (self, ...) -> ...
records (they drift and cost enormous churn on big classes). Inheritance: list only the child's
own new fields; inherited ones arrive via & Parent.Parent. Generics: keep <T> load-bearing
by putting it in a field (e.g. a virtual Value: T); never collapse <T> to any. The one tax:
a metatable'd type needs self :: any for dynamic self-access (rawget(self :: any, k)).
When :: any is acceptable
Confined to boundaries the checker genuinely can't model:
setmetatable(ParentClass.new(...) :: any, MyClass)— the metatable transformBinder.new("Tag", MyClass :: any) :: Binder.Binder<MyClass>— binder registrationlocal t: any = require("t")—t(and the rare library like it) isn't strict-friendly- Rx / reactive chains (
:Pipe({...}),switchMap/mapclosures,RxSignal,Signal.new()) — the #1 time-sink. Don't try to thread types through them; cast toanyon the first analyze error and move on, keeping the public return type precise. Seereferences/rx.md.
Cast the chain body, never the public return type. Type every public method's return precisely
(Promise.Promise<T>, Observable.Observable<T>); only the internal expression producing it may
be any. Casting the return type itself is the single biggest source of avoidable looseness — a
GetX(self): any leaks any to every caller. See the Rx/Promise examples in references/conventions.md.
Reaching for :: any inside a method body means the export type block is wrong — fix it there.
A call-site (x :: any):Method() to dodge a signature mismatch is a smell: usually the upstream
signature is too strict (a param that should be optional) — fix it upstream if in scope, else flag
it. Don't bury a real type bug under a cast.
Hard cases — try the precise type first, loosen the narrowest spot only
Concrete metatable types (Signal.Signal<T>, ValueObject.ValueObject<T>, Maid.Maid) import
and check cleanly almost always — write them out. Only these justify deviating:
- A type you cannot import (sibling is still nonstrict, exports no type) → write a precise
structural interface of the exact surface you use (real signatures, real field types) — this
is typing by another means, not a loosening. Better, if cheap: add the
export typeupstream. - A heavy cyclic generic the old solver can't hold (a deep class wrapped across many
members) → type each occurrence as
anywith JUST the intended type in a trailing comment:_indexObservers: any, -- ObservableSubscriptionTable<T?>. Find the one culprit type and sweep all its occurrences in one pass (fields, params, returns), don't bisect. - Require cycle between a class and its definition/factory → hoist shared shapes into a
types-only
<Package>Types.lua(requires nothing at runtime, so importing it can't recreate the cycle). Keep the public API precise; only the internal back-reference may give.
Time-box it: if a file won't go clean within ~2 iterations on the same cyclic/"too complex" error, take the escape for that member and move on. The public API must stay precise; internal plumbing can absorb imprecision.
Comment discipline
Default: write loose anys BARE — no explanatory comment. Write _cmdrService: any, — NOT
_cmdrService: any, -- CmdrServiceClient (nonstrict, no exported type). This holds for every any
whose source module simply exports no type yet (the common case). The comment reads as "blessed,
intentional" and discourages the next person from tightening it — and we want these anys gone. A
bare any is honest debt; a commented one looks finished. Don't explain yourself.
Only two exceptions earn a comment:
- A forced cyclic-complexity
any(Hard case #2) records its intended type so it's recoverable:_indexObservers: any, -- ObservableSubscriptionTable<T?>. The comment is information, not an apology. - A non-obvious deliberate structure (e.g. a structural type dodging a require cycle) gets a line so no one "fixes" it back into the cycle.
If you're tempted to comment an any for any other reason — don't.
Report your hand-offs
End with a short "Hand-offs" list: every spot where you took an escape instead of a precise
type — file, member, the any/structural fallback, and the intended type. Skip routine sanctioned
boundaries (metatable casts, t: any, binder registration). This is the deliverable that lets the
user tighten later; a run that loosened things silently is not done.
Pitfalls
--!strictmust be the file's FIRST line — before the--[=[ ]=]docstring. Luau only honors a mode directive at the very top; placed after the docstring it's silently ignored and the file is never strict-checked (analyze looks "clean" but isn't).- Don't leave
--!nonstrict/--!nocheck"for safety" — the point is--!strict. - Don't run repo-wide
lint:luauon every edit — single-file analyze is the inner loop,lint:luauis the final gate. - Don't invent fields — the
export typeblock must list exactly whatselfgets. - Keep legacy/deprecation docstrings; typing doesn't change deprecation status.
Tooling
references/conventions.md— full canonical examples + a common-error → fix table.references/rx.md— the Rx/reactive escape in detail.Converting a whole package? Don't eyeball the order. Run the planner for a dependency-ordered, model-routed conversion plan — which files, in what order, and which form cyclic clusters to convert together:
# from the repo root (the script cd's to the repo top itself, so CWD doesn't matter): .claude/skills/strict-typing-luau/evals/lib/run.sh plan src/<package> # e.g. src/settingsIt defaults to real mode (targets the files that aren't
--!strictyet); convert leaves-first in the order it prints. (--eval-goldswitches it to the harness's gold-bearing view.)Parallelize at scale. Files in the same dependency layer of the plan are independent — they don't require each other, so they can convert concurrently. When a package has more than ~3 files to convert, fan out one sub-agent per file within a layer (launch them in a single message so they run in parallel), wait for the layer, then move to the next. Each sub-agent converts ONE file against its already-converted deps. This makes wall-clock ≈ the slowest file per layer instead of the sum, and keeps each context small. At ≤3 files, just convert sequentially — the fan-out overhead isn't worth it.
That same
run.shwith no arguments lists every command.planis the one generally useful for real conversions; the rest (gold,convert,place/score/restore,triggers) evaluate the skill — seeevals/README.mdfor those workflows.
Source: Quenty/NevermoreEngine — distributed by TomeVault.