quenty/nevermoreengine/strict-typing-luau
Claude Code and agent skill by quenty/nevermoreengine
- community
Checking that the SKILL.md file is still in the repository.
Convert a Nevermore Luau file from --!nonstrict (or untyped/--!nocheck) to --!strict, adding the project's explicit type annotations and fixing every type error the checker reports. Use this whenever the user asks to "strictly type", "add types to", "make strict", "type-annotate", "convert to --!strict", or clean up the typing on a .lua/.luau file in this repo — including when they just select a file with a `--!nonstrict` header and say "type this" or point you at a legacy module. Also use it when a strict file is throwing luau-lsp type errors and the user wants them resolved following Nevermore conventions.
Install
npx vskill@latest install quenty/nevermoreengine/strict-typing-luauSKILL.md
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; no
setmetatable): flip the header, give every function typed params/returns, run single-file analyze. Usually no export type block needed.
- Class (
setmetatable({}, ...)+ constructor + methods): the real work — needs the
export type block. Follow the patterns below and references/conventions.md.
- Already `--!strict` but 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.
Clean = only the [INFO] Loading... line. LuauSolverV2=false is required (repo pins the old solver). Never drop `--defs=globalTypes.d.lua` or `--base-luaurc=.luaurc` — without --defs the analyzer loses the Roblox global declarations and reports false Unknown global 'tick'/'time' (and similar) errors while still resolving game/Enum, which looks like a real conversion bug but isn't. If you see unknown-global errors only on deprecated globals, you forgot --defs; confirm with npm run lint:luau (which always passes the flag) before "fixing" anything. 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.
The gate is both lint:luau (types) and lint:selene (lints), which exits non-zero. Dot-syntax conversion routinely trips selene even when analyze is clean: an unused_variable: self (method body ignores the now-explicit param → rename it `_self`) or a shadowing from an Rx escape local X = X :: any inside a function (→ cast at the source, local X: any = require("X")). See the "selene" section in references/conventions.md.
Core patterns
Class with a parent (the common case):
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:
⚠️ Metamethod classes — NEVER rewrite rawget/rawset or hoist setmetatable
The patterns above assume the ordinary metatable (__index = MyClass, default __newindex). Some classes define a custom `__index` and/or `__newindex` function that intercepts field access — often to expose computed keys (.Value, .Changed) and to `error()` on any unknown key. Grep for (MyClass :: any).__index = function / .__newindex = function before touching the constructor or any self._field access. For these classes, two edits that look like harmless cleanups are runtime-breaking changes — do not make them:
- **Do not replace
rawget(self, "_x")withself._x, orrawset(self, "_x", v)with
self._x = v.** The raw calls are load-bearing: they deliberately bypass the custom metamethod. Routing the access through self._x fires the metamethod, which may compute a different value or error("Bad index") — silently for present fields, fatally for absent ones (e.g. lazy caches that start unset). Keep every rawget/rawset exactly as-is; the only allowed change is adding the receiver cast: rawget(self, "_x") → rawget(self :: any, "_x") (and :: any on the result if the checker complains about any?).
- Do not hoist `setmetatable` above the field assignments in the constructor. The strict
pattern local self = setmetatable(Base.new() :: any, MyClass) followed by self._x = ... is correct only when __newindex is the default (raw write). If the class has a custom __newindex that errors on unknown keys, that pattern makes every field assignment throw "Bad index". Build a plain table first, then apply the metatable last — the original order:
``lua function MyClass.new(...): MyClass -- __newindex errors on unknown keys: assign fields BEFORE the metatable. local self = {} :: any self._serviceBag = assert(serviceBag, "No serviceBag") self._definition = assert(definition, "Bad definition") return setmetatable(self, MyClass) end ``
(Equivalently, keep setmetatable first but write each field with rawset(self :: any, "_x", v).) When a subclass wraps a metamethod parent — setmetatable(Parent.new(...) :: any, MyClass) — the fields it adds must use rawset too, since MyClass.__newindex will reject them.
These classes carry no test coverage during conversion, so the checker won't catch either mistake — they only surface at runtime. When in doubt, preserve the original access/order verbatim and just add casts.
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:
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 to any on the first analyze error and move on, keeping the public return type precise. See references/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 type upstream.
- A heavy cyclic generic the old solver can't hold (a deep class wrapped across many
members) → type each occurrence as any with 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 `any`s 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
This is the start of the file. Read the full SKILL.md on GitHub.
Security
- Security scanPassed, 91 of 100Pattern checks on the skill's files.
- Model reviewNot runA model review is scheduled for the next rescan.
Quality evaluation
- Task evaluationNot runNo evaluation yet. Run one in Skill Studio.
Source
- SKILL.md in the repositoryCheckingtools/nevermore-claude/skills/strict-typing-luau/SKILL.md
Popularity
- Repository stars
- 606
- Repository forks
- 144
- 7-day trend score
- 32
Stars and forks are for the whole GitHub repository, not this skill alone. Not refreshed yet.
Works with
Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.
- Claude Code
- Cursor
- GitHub Copilot
- Windsurf
- Codex
- Gemini CLI
- Cline
- Amp
- Roo Code
- Goose
- Continue
- Kilo Code
- Junie
- Trae
- OpenHands
- Qwen Code
- AI Agent
- Tabnine Chat
- Sourcegraph Cody
- Amazon Q
- Firebase Genkit
- Sweep
- Devin
- Phind
- Mentat
- Claude Desktop
- Aider
- GPT Pilot
- Plandex
- bolt.new
- v0
- Replit Agent
- Anse
- TypingMind
- BetterChatGPT
- LibreChat
- Warp AI
- Pieces
- Fig AI
Embed badge
[](https://verified-skill.com/skills/quenty/nevermoreengine/strict-typing-luau)