Luau Type Checking in Strict Mode: Annotating Roblox Game Code So Bugs Surface Before Playtest

Have you ever typed --!strict at the top of a Roblox script, watched the Script Analysis window fill with underlines, and quietly deleted the comment again? If you have shipped a game with more than a handful of ModuleScripts, you probably also know the other side of that trade — the nil index that only shows up twenty minutes into a playtest, on the one code path nobody clicked.
Luau's type checker exists to move that second kind of bug into the first kind of moment. This guide walks through strict mode in the order you would actually adopt it — annotations, modules, remotes, generics, and a rollout plan — and it assumes you already have the module structure from our Luau scripting patterns guide in place.
Adding --!strict as the first line of a script tells the Luau analyzer to infer a type for every value and flag mismatches in Script Analysis. Nothing changes at runtime, so the warnings arrive before you press Play.
What Does Strict Mode Actually Change?
Every Luau script runs under one of three analysis modes, set by a comment on the very first line of the file. The mode controls how much the analyzer infers and how much it reports, and it has no effect on how the code executes.
Luau has three analysis modes: --!nocheck, --!nonstrict, and --!strict. Nonstrict is the default and treats most unannotated values as any, which is why it stays quiet about bugs that strict mode reports.
The three modes compare as follows:
| Mode | What the analyzer infers | What it reports | Where it fits |
|---|---|---|---|
--!nocheck | Nothing | Nothing | Vendored or generated code you do not maintain |
--!nonstrict (default) | Unannotated locals and parameters largely fall back to any | Only clear-cut mistakes | Legacy scripts in the middle of a migration |
--!strict | A type for every expression | Mismatched arguments, possible nil access, missing or misspelled fields | New code and shared modules |
In practice, the difference shows up in the lines you thought were fine. A misspelled property on an unannotated parameter passes silently under nonstrict because the parameter is treated as any, whereas the same typo on a parameter annotated as Player in a strict script is underlined as you type it.
Keep in mind that type errors surface as warnings and never block a script from running. This is why the rollout section below treats a clean analysis pass as a team rule.
How To Annotate Variables, Functions, And Tables
Annotation syntax in Luau is small: a colon after a name introduces its type, and a colon after a parameter list introduces the return type. Most locals need no annotation at all, because strict mode infers them from the value on the right-hand side.
Function signatures are where annotations pay for themselves. For example, a damage helper that states its inputs and its output looks like this:
--!strict
local function applyDamage(humanoid: Humanoid, amount: number): number
local remaining = math.max(humanoid.Health - amount, 0)
humanoid.Health = remaining
return remaining
end
With that signature in place, a caller that passes a Model instead of a Humanoid, or a string instead of a number, is flagged at the call site. What's more, autocomplete inside the function now knows every member of Humanoid.
Tables are the next step, since nearly all game state lives in them. Luau describes table shapes with type aliases, and the building blocks you will reach for include but are not limited to:
- Record shapes. A type such as
{ id: string, stack: number }describes a table with fixed, named fields. Reading or writing a field that is not in the shape is reported. - Arrays and dictionaries.
{ Item }is an array of items, and{ [string]: Item }is a dictionary keyed by string. - Optionals. A trailing question mark, as in
number?, means the value may be nil. The analyzer then requires a nil check before you do arithmetic on it. - String singletons and unions.
"Common" | "Rare" | "Legendary"restricts a field to those exact strings. A typo in a rarity name becomes an analysis warning instead of a comparison that silently never matches.
All of these combine into the kind of definitions a real inventory needs:
type Rarity = "Common" | "Rare" | "Legendary"
type Item = {
id: string,
rarity: Rarity,
stack: number,
expiresAt: number?,
}
type Inventory = { [string]: Item }
type Hotbar = { Item }
These are the same shapes our Roblox inventory systems guide builds on. Once the alias exists, every function that accepts an Item is checked against it.
For config tables you have already written, there is a shortcut. type Config = typeof(DEFAULTS) derives the type from the existing value, so the table and its type cannot drift apart.
How To Type A ModuleScript So Other Scripts Inherit It
A type declared with type is private to its script. To share it, declare it with export type, and every script that requires the module can refer to it through the module's local name.
Use export type inside a ModuleScript to share a type with other scripts. Any script that requires the module can then reference it as Module.TypeName, as long as the require path is one the analyzer can follow.
--!strict
-- ReplicatedStorage/Shared/ItemTypes
local ItemTypes = {}
export type Rarity = "Common" | "Rare" | "Legendary"
export type Item = { id: string, rarity: Rarity, stack: number }
return ItemTypes
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ItemTypes = require(ReplicatedStorage.Shared.ItemTypes)
local function grant(player: Player, item: ItemTypes.Item)
print(player.Name, item.rarity)
end
Note that the analyzer has to be able to follow the require path. A direct path such as ReplicatedStorage.Shared.ItemTypes resolves cleanly, whereas a require built from a variable or a computed string generally comes back as any, and the types quietly disappear.
Class-style modules built on metatables need one extra line. The common idiom is to derive the instance type from setmetatable itself, so that fields and methods are both visible to callers:
--!strict
local Wallet = {}
Wallet.__index = Wallet
export type Wallet = typeof(setmetatable({} :: { coins: number }, Wallet))
function Wallet.new(): Wallet
return setmetatable({ coins = 0 }, Wallet)
end
function Wallet.add(self: Wallet, amount: number)
self.coins += amount
end
return Wallet
Notice that the method is written with a dot and an explicit self: Wallet parameter instead of a colon. That gives the analyzer a precise type for self, and callers can still write wallet:add(5) as usual.
How To Type RemoteEvents Without Trusting The Client
Remotes are where typed Luau can give developers false confidence. A RemoteEvent crosses the network boundary, and whatever arrives on the server is whatever the sending client chose to send.
Types are erased before the code runs, so an annotation on a remote handler validates nothing. Declare client arguments as unknown and narrow them with typeof checks, and the analyzer will enforce that you did.
The pattern that holds up splits the job in two. First, a shared module exports the payload types so the client's FireServer wrapper and the server's handler agree on the contract; then, the server handler accepts unknown and proves each argument before using it.
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local purchaseRemote = ReplicatedStorage:WaitForChild("Purchase") :: RemoteEvent
local function onPurchase(player: Player, itemId: unknown, quantity: unknown)
if typeof(itemId) ~= "string" or typeof(quantity) ~= "number" then
return
end
if quantity ~= quantity or quantity < 1 or quantity % 1 ~= 0 then
return
end
Shop.purchase(player, itemId, quantity)
end
purchaseRemote.OnServerEvent:Connect(onPurchase)
Because itemId and quantity start as unknown, the analyzer refuses to let them reach Shop.purchase until the typeof checks have narrowed them. As a result, deleting the validation produces a type error, and the type system ends up enforcing part of your security review.
Be aware that typeof(quantity) == "number" still admits NaN, infinity, negatives, and fractions. Range checks like the ones above are part of the contract, and no annotation will write them for you.
The cast on the WaitForChild line matters as well. WaitForChild returns a plain Instance, so the :: cast tells the analyzer it is a RemoteEvent; our Roblox replication guide covers what should and should not cross that boundary in the first place.
When Do Generics Earn Their Keep?
Generics can look academic until you write the same helper for the third time. They are worth reaching for whenever a function or container should work with many types yet preserve which one it was given.
A generic is a type parameter written in angle brackets, such as Result<T>. It lets one function or table type work with many value types while the analyzer still tracks which type went in and which comes out.
type Result<T> = { ok: true, value: T } | { ok: false, err: string }
local function pickRandom<T>(list: { T }): T?
if #list == 0 then
return nil
end
return list[math.random(1, #list)]
end
local result: Result<Profile> = loadProfile(player.UserId)
if result.ok then
print(result.value.coins)
else
warn(result.err)
end
The Result type above is a tagged union, and it is a particularly useful shape in Roblox code that touches DataStores or HTTP requests. Once the code checks result.ok, the analyzer narrows the union: the true branch has a value, the false branch has an err, and reading the wrong one is flagged.
That said, generics are easy to overuse. If a function only ever handles one type, a concrete annotation reads better and produces clearer warnings.
Working With Instances, Optionals, And Refinement
The Roblox API is typed, which means strict mode knows that FindFirstChild can return nil and that a plain Instance has no Anchored property. A good portion of the warnings in a newly strict script tend to trace back to these two facts.
The fix is refinement, where a runtime check narrows the type for the code that follows it. For instance:
local checkpoint = workspace:FindFirstChild("Checkpoint")
if checkpoint and checkpoint:IsA("BasePart") then
checkpoint.Anchored = true
end
After the and, the analyzer treats checkpoint as a BasePart, so Anchored is legal. The same mechanism works with typeof checks, comparisons against nil, and assert.
The :: cast is the escape hatch, and it deserves some restraint. A cast asserts something the analyzer cannot verify, so you may want to consider keeping casts to genuine boundaries such as WaitForChild results and decoded JSON.
Common Strict Mode Warnings And How To Read Them
The first strict conversion usually produces a short list of repeating messages. Some examples of the ones worth learning to read on sight include:
- "Value of type 'Instance?' could be nil." An optional was used without a check. An if statement, an assert, or a default supplied with
orresolves it. - "Key 'Charcter' not found in class 'Player'." Either the name is misspelled or a type alias is missing a field. The alias doubles as documentation, so it is worth keeping truthful.
- "Type 'string' could not be converted into 'number'." An argument or assignment does not match the declared type. On long table types, the final line of the message usually names the specific field that disagrees.
- "Unknown require" or a module typed as any. The analyzer could not follow the path. Replacing the dynamic lookup with a direct path restores the module's types.
Overall, each of these messages points at an assumption the code was already making. Strict mode asks you to write the assumption down where the analyzer can check it.
How To Roll Strict Mode Across An Existing Codebase
Flipping every script to strict in one afternoon tends to produce a wall of warnings and an abandoned branch. The order of conversion matters more than the speed.
Convert leaf modules first — shared types, config tables, and utilities with no dependencies. Each strict module then hands precise types to everything that requires it, so later conversions produce fewer warnings.
Here's a sequence that keeps the warning count manageable:
- Shared types first. Create or convert the modules that only export types and constants. They have nothing to break, and they feed everything else.
- Pure utilities next. Math helpers, table helpers, and formatters have narrow signatures and few Instance dependencies.
- Then services. Data, inventory, and economy modules gain the most, since their callers inherit precise types on the next require.
- Entry-point scripts last. Server and client bootstrap scripts touch the most Instances and benefit from everything beneath them already being typed.
- Code you won't maintain. Vendored libraries and generated files can carry
--!nocheckso they stop adding noise.
All of these steps go faster if your project already syncs through Rojo. With a sourcemap from rojo sourcemap, luau-lsp can analyze the whole tree in your editor or in CI, and a .luaurc file can set strict as the project default — see our Rojo and Git workflows guide for the setup.
Remember that the analyzer and your test suite catch different things. Types prove that shapes line up, while the specs described in our Roblox unit testing guide prove that the logic inside those shapes is correct.
Finally, be aware that Roblox has been moving Luau to a new type solver, and behavior at the edges — inference for unannotated parameters, table handling, the wording of errors — differs between the old solver and the new one. The official Roblox type checking documentation is the reference to check when a warning does not match what you read here.
Frequently Asked Questions About Luau Strict Mode
The questions below cover the edges that the sections above only touch on. Each answer stands on its own, so you can jump to the one you need.
Does a Luau type error stop a Roblox script from running?
No. Type errors appear as warnings in Script Analysis and in external tools such as luau-lsp, but the script still compiles and runs. Treat a clean analysis pass as a team rule or a CI check instead.
Do Luau type annotations make game code run faster?
In ordinary scripts, no — annotations are stripped before execution. The exception is native code generation, where annotations on function parameters such as Vector3 or buffer can help the compiler emit specialized code.
Can I mix strict and nonstrict scripts in the same Roblox place?
Yes. The mode comment applies per script, so strict and nonstrict files coexist. Values that cross from a nonstrict module typically arrive as any, which is why converting shared modules first pays off fastest.
What is the difference between any and unknown in Luau?
any switches checking off for a value, so every operation on it is allowed. unknown accepts every value but permits nothing until a typeof check or IsA call narrows it, which makes it the safer choice for client-supplied remote arguments.
How do I run Luau type checking outside Roblox Studio?
Use luau-lsp in your editor or in CI, fed by a sourcemap generated with rojo sourcemap so it understands your instance tree. A .luaurc file with languageMode set to strict applies the mode across the project.
Put The Analyzer To Work Before Your Next Playtest
Strict mode costs a first-line comment and a habit of annotating function signatures. In return, misspelled fields, nil Instances, and mismatched remote payloads show up in Script Analysis while the code is still on your screen.
Are you partway through a migration and unsure what to convert next? Start with one shared types module this week, and bring the specific warning you're stuck on to our concierge desk — it works through exactly these Luau annotation questions with the code in front of it.


