Appearance
Narrowing and Guards
Written: 2026-10-14
Learning objectives
- You narrow unknown before use.
- You refine with type checks, truthiness, assert, and singleton equality.
- You guard optionals with nil checks.
- You handle LocalPlayer and FindFirstChild nil cases.
- You model choices with discriminated unions and exhaust them.
- You wrap checks in guard helpers.
Prerequisites
Unknown must shrink before you touch it
unknown means you know nothing yet. You prove a little, then you use that little.
The precise rule is narrow-first. An unknown value cannot be used as a number, string, or table until a guard refines it. Guards include type() checks, typeof() checks, truthiness tests, assert, and equality against a singleton. The luau.org types intro presents unknown as the top type you must narrow. The refinement model with truthy tests, type guards, equality, not flips, and assert is documented in the types guide type refinements. The top and bottom roles of unknown and never are defined in never and unknown.
Refinement lives on the guarded branch. Outside that branch the value stays wide. Custom helper calls do not automatically refine their arguments: the type refinements page lists only truthiness, type() guards, equality, not flips, and assert as refinements.
You check early and return early. Happy-path code sits below the guards. Each branch knows exactly what it holds.
Put this Script in ServerScriptService.
luau
--!strict
local function coinTotal(raw: unknown): number
if typeof(raw) == "number" then
return raw
end
return 0
end
local got = coinTotal(7)This block checks clean. You infer got as number. Inside the if, raw refines to number. Outside, it stays unknown. A direct raw + 1 without the guard would error because unknown supports no arithmetic.
Put this Script in ServerScriptService.
luau
--!strict
local function labelOf(value: unknown): string
if type(value) == "string" then
return value
end
return "empty"
end
local got = labelOf("Mara")This block checks clean. You infer got as string. type() reports Lua primitives and refines the branch per type refinements. typeof() reports Engine-extended names; use it to read Engine values, and keep the branch guard explicit since the refinements page documents type() as the guard form. You pick type() for Lua values and typeof() for Engine values.
Truthiness and singletons refine without new syntax
if flag then already says the flag is on. if mode == "ready" then already says which case you hold.
The precise rule is refinement by test. A truthy test refines to a truthy type. not and ~= flip the refinement. Equality against a literal refines to that singleton. assert with the same test refines after it. These forms are listed in type refinements.
You test booleans by truthiness. You do not compare them to true or false with ==. Singleton comparison fits string and literal cases, not boolean flags.
You write if flag then and if not running then. You write if kind == "Sword" then. The checker follows the same reading you do.
Put this Script in ServerScriptService.
luau
--!strict
local flag = true
local running = false
local first = if flag then 10 else 0
local second = if not running then 1 else 2This block checks clean. You infer first as number and second as number. Truthiness and negation guide the branches. No equality against booleans is needed.
Put this Script in ServerScriptService.
luau
--!strict
local function readyBonus(state: "ready" | "waiting"): number
if state == "ready" then
return 50
end
return 0
end
local got = readyBonus("waiting")This block checks clean. You infer got as number. On the true branch state refines to the singleton "ready". On the fallthrough it holds "waiting". The union plus equality gives you case analysis for free.
Put this Script in ServerScriptService.
luau
--!strict
local function pay(raw: unknown): number
assert(typeof(raw) == "number")
return raw * 2
end
local got = pay(6)This block checks clean. You infer got as number. assert refines raw to number after it. A failing assert stops the run, so later code can trust the narrowed type.
Optionals need an explicit nil guard
An optional may be absent. You ask first, then you use.
The precise rule is ~= nil. A value of type T? holds either T or nil. A check like if item ~= nil then refines the branch to T. The syntax tour defines T? as shorthand for a nil union. The type checking guide shows the same optional form.
A falsy check also removes nil, but it removes false and other falsy shapes too when the inner type allows them. For Instances and records you want the precise ~= nil test.
You guard, then you call methods. You never chain a method directly off a ? value.
Put this Script in ServerScriptService.
luau
--!strict
local function nameLength(name: string?): number
if name ~= nil then
return string.len(name)
end
return 0
end
local got = nameLength("Mara")This block checks clean. You infer got as number. Inside the guard name is string. Outside it may be nil. A direct string.len(name) without the guard errors because the arg may be absent.
LocalPlayer and FindFirstChild are nil patterns
Some Engine reads have no value in some runs. Server has no LocalPlayer. A missing child has no Instance.
The precise rule is guard every lookup. Players.LocalPlayer is nil on the server, it is only defined for LocalScripts and required ModuleScripts (see Players). FindFirstChild returns a nilable Instance. WaitForChild with a timeout returns nil after the timeout per the Instance reference. You test ~= nil before you read fields or call methods.
The limitation is the type database. Declared nilability can lag behind runtime reality. When a call is correct but the declared type disagrees, you still guard first and reserve casts for genuine drift. Casts are covered in Casts and Assertions.
Character code, GUI code, and tool code survive missing objects. You fail with a clear early return instead of an indexing crash.
Put this LocalScript in StarterPlayerScripts.
luau
--!strict
local Players = game:GetService("Players")
local player = Players.LocalPlayer
local guiCount = 0
if player ~= nil then
local gui = player:WaitForChild("PlayerGui")
guiCount = 1
endThis block checks clean. You infer player as Player? before the guard and Player inside it. The method call sits only where the value exists. A direct player:WaitForChild without the guard errors on the nil case.
Put this Script in ServerScriptService.
luau
--!strict
local function toolCount(holder: Model): number
local tool = holder:FindFirstChild("Sword")
if tool ~= nil then
return 1
end
return 0
end
local dummy = Instance.new("Model")
local got = toolCount(dummy)This block checks clean. You infer got as number. tool starts as Instance?. The guard refines it to Instance where you use it. You do not assume the child exists.
Discriminated unions plus else-never exhaust choices
Each case carries its own tag. You branch on the tag and handle every label.
The precise rule is tag plus union. You define cases like { kind: "coin", amount: number } | { kind: "badge", id: string }. A test on kind refines the whole record to one member. An else that should be unreachable holds never. The never exhaustiveness pattern is defined in never and unknown.
The tag field must be a singleton in each member. A loose string tag does not discriminate. A missing tag leaves the union ambiguous.
Add a new case and unhandled branches error or hit your never trap. You find the missing branch in the editor.
Put this Script in ServerScriptService.
luau
--!strict
export type Reward =
{ kind: "coin", amount: number }
| { kind: "badge", id: string }
local function describe(reward: Reward): string
if reward.kind == "coin" then
return tostring(reward.amount)
elseif reward.kind == "badge" then
return reward.id
else
local trap: never = reward
return trap
end
end
local got = describe({ kind = "coin", amount = 5 })This block checks clean. You infer got as string. Each branch refines reward to one member. The final else sees never because no case remains. Assigning reward to never proves exhaustiveness. Add a third case and the trap errors until you handle it.
Guard helpers keep call sites short
The same check appears in many places. You name it once.
A helper returns a boolean or an optional. The caller branches on that return. Luau does not infer argument narrowing from a custom boolean helper alone: only the caller's own if, assert, or ~= nil on the helper result refines, per the type refinements list. You get narrowing from the caller's own if, assert, or ~= nil on the helper result.
A helper named isNumber does not refine its input at the caller. Only the helper's return refines. You still write the branch at each use.
Helpers document intent. Branches keep the proof local. You avoid predicates that hide the test.
Put this ModuleScript in ReplicatedStorage.
luau
--!strict
local function asNumber(raw: unknown): number?
if typeof(raw) == "number" then
return raw
end
return nil
end
local function doubled(raw: unknown): number
local value = asNumber(raw)
if value ~= nil then
return value * 2
end
return 0
end
local got = doubled(9)This block checks clean. You infer got as number. asNumber returns number?. The ~= nil test refines value to number. The helper hides the typeof detail but the caller keeps the branch that proves safety.
Common mistakes
Wrong: you use an unknown without narrowing.
Put this Script in ServerScriptService.
luau
--!strict
local function bad(raw: unknown): number
return raw + 1
endThis block errors. unknown supports no arithmetic. You fix it with a typeof guard and an early return.
luau
--!strict
local function good(raw: unknown): number
if typeof(raw) == "number" then
return raw + 1
end
return 0
endThis fix checks clean. You infer good as (unknown) -> number. The addition sits only where raw is number.
Wrong: you call through an optional.
Put this Script in ServerScriptService.
luau
--!strict
local function bad(name: string?): number
return string.len(name)
endThis block errors. name may be nil. You fix it with a nil guard.
luau
--!strict
local function good(name: string?): number
if name ~= nil then
return string.len(name)
end
return 0
endThis fix checks clean. You infer good as (string?) -> number.
Wrong: you skip the FindFirstChild guard.
Put this Script in ServerScriptService.
luau
--!strict
local function bad(holder: Model): string
local tool = holder:FindFirstChild("Sword")
return tool.Name
endThis block errors. tool is nilable. You fix it with a guard.
luau
--!strict
local function good(holder: Model): string
local tool = holder:FindFirstChild("Sword")
if tool ~= nil then
return tool.Name
end
return "none"
endThis fix checks clean. You infer good as (Model) -> string.
Exercises
- Narrow an unknown payload to number with an early return.
- Refine a singleton union with equality.
- Guard a string optional and read its length.
- Guard LocalPlayer before reading PlayerGui.
- Exhaust a two-case discriminated union with an else-never trap.
Solution 1
Put this Script in ServerScriptService.
luau
--!strict
local function total(raw: unknown): number
if typeof(raw) == "number" then
return raw
end
return 0
end
local got = total(4)This block checks clean. You infer got as number. The guard refines raw to number on one path. The fallback keeps the function total.
Solution 2
Put this Script in ServerScriptService.
luau
--!strict
local function fee(kind: "coin" | "free"): number
if kind == "coin" then
return 10
end
return 0
end
local got = fee("free")This block checks clean. You infer got as number. Equality refines kind to "coin" on the true branch. The fallback covers "free".
Solution 3
Put this Script in ServerScriptService.
luau
--!strict
local function count(name: string?): number
if name ~= nil then
return string.len(name)
end
return 0
end
local got = count("Quill")This block checks clean. You infer got as number. The guard proves presence before the length read.
Solution 4
Put this LocalScript in StarterPlayerScripts.
luau
--!strict
local Players = game:GetService("Players")
local me = Players.LocalPlayer
local ready = false
if me ~= nil then
ready = me:GetAttribute("Ready") == true
endThis block checks clean. You infer ready as boolean. The attribute read sits inside the nil guard. Server runs keep ready false without crashing.
Solution 5
Put this Script in ServerScriptService.
luau
--!strict
export type Loot =
{ kind: "gem", count: number }
| { kind: "map", zone: string }
local function label(loot: Loot): string
if loot.kind == "gem" then
return tostring(loot.count)
elseif loot.kind == "map" then
return loot.zone
else
local trap: never = loot
return trap
end
end
local got = label({ kind = "map", zone = "Dunes" })This block checks clean. You infer got as string. The else branch holds never. A new loot kind breaks the trap until you add its branch.
Summary
- You narrow unknown before use with type checks, truthiness, assert, and singleton equality.
- You guard optionals with
~=nil. - You guard LocalPlayer and FindFirstChild before use.
- Discriminated unions plus else-never prove you handled every case.
- Guard helpers return values you branch on locally.
- Next: Casts and Assertions.