/strict-typing-luau
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",
$ npx -y skills add Quenty/NevermoreEngine --skill strict-typing-luau --agent claude-codeHow it fires
How this skill gets triggered: by you, by Claude, or both.
- Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
- You can call itInvoke it directly when you want it.
- Slash command
/strict-typing-luau
Context preview
The summary Claude sees to decide when to auto-load this skill.
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",
SKILL.md
strict-typing-luau.SKILL.mdname: strict-typing-luau
description: 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.
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 a `return {...}` 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`.
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). **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):**
--!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
⚠️ 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:
1. **Do not replace `rawget(self, "_x")` with `self._x`, or `rawset(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?`).
Read more
name: strict-typing-luau description: 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.
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 a `return {...}` 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`.
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). **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):**
--!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
⚠️ 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:
1. **Do not replace `rawget(self, "_x")` with `self._x`, or `rawset(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?`).
ModuleScript loader with reusable and easy unified server-client modules for faster game development on Roblox

