---
url: /strict-typing/narrowing-and-guards.md
description: Prove unknown values safe with guards, optionals, and exhaustive unions.
---

# Narrowing and Guards

*Written: {{ $frontmatter.date }}*

## 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

* [Annotations and Inference](./annotations-and-inference.md)
* [Modes](./modes.md)
* [Errors and Defensive Code](../luau-basics/errors-and-defensive-code.md)

## 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](https://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](https://luau.org/types/type-refinements/). The top and bottom roles of `unknown` and `never` are defined in [never and unknown](https://rfcs.luau.org/never-and-unknown-types.html).

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](https://luau.org/types/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](https://luau.org/types/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](https://luau.org/types/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 2
```

This 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](https://luau.org/syntax/) defines `T?` as shorthand for a nil union. The [type checking](https://create.roblox.com/docs/en-us/luau/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](https://create.roblox.com/docs/reference/engine/classes/Players)). `FindFirstChild` returns a nilable Instance. `WaitForChild` with a timeout returns nil after the timeout per the [Instance](https://create.roblox.com/docs/reference/engine/classes/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](./casts-and-assertions.md).

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
end
```

This 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](https://rfcs.luau.org/never-and-unknown-types.html).

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](https://luau.org/types/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
end
```

This 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
end
```

This 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)
end
```

This 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
end
```

This 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
end
```

This 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"
end
```

This fix checks clean. You infer `good` as `(Model) -> string`.

## Exercises

1. Narrow an unknown payload to number with an early return.
2. Refine a singleton union with equality.
3. Guard a string optional and read its length.
4. Guard LocalPlayer before reading PlayerGui.
5. Exhaust a two-case discriminated union with an else-never trap.

::: details 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.
:::

::: details 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"`.
:::

::: details 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.
:::

::: details 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
end
```

This block checks clean. You infer `ready` as `boolean`. The attribute read sits inside the nil guard. Server runs keep `ready` false without crashing.
:::

::: details 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](./casts-and-assertions.md).
