Appearance
Errors and Defensive Code
Written: 2026-10-14
Learning objectives
- You raise errors with error and assert at the right level.
- You contain failures with pcall and xpcall.
- You apply or-defaults without hiding false.
- You model optional values with nil checks.
- You validate inputs in a fixed order.
- You write readable contracts callers can trust.
Prerequisites
error stops with a message and a level
Use error when the function cannot keep its promise. Pass a message plus an optional level. Level 1 points at the error call itself. Level 2 points at the caller. Pick level 2 for library helpers so Studio highlights the bad call, not the guard.
Put this Script in ServerScriptService.
luau
--!strict
local function take(list: { string }, i: number): string
if i < 1 or i > #list then
error(`index {i} past bounds`, 2)
end
return list[i]
end
print(take({"a", "b"}, 1))txt
aValid input returns normally. Out-of-range input errors at the caller line because of level 2. Reserve error for contract breaks: bad indices, missing config, and wrong types that callers must fix. Never use it for normal control flow.
Keep messages specific. Name the value, the rule, and the fix. "index 9 past bounds" beats "bad index". Good messages turn a stack trace into instructions. The globals reference behind functions documents the same protected-call pairing used here.
assert checks and returns through
Use assert to demand a truthy value. It returns all its inputs when the check passes. It errors when the value is nil or false. The default message is assertion failed!. Pass your own message for context.
Run this pure snippet in the Command Bar.
luau
--!strict
local function half(x: number): number
assert(type(x) == "number", "half needs a number")
return x / 2
end
print(half(10))txt
5The type check passes and the function returns 5. A string argument would error with your message instead. Place asserts at the top of helpers for preconditions. Place them after calls for postconditions you must guarantee.
Asserts are for programmer errors, not player typos. A bad internal table shape deserves an assert. A chat command with a missing number deserves a friendly fallback, not a crash. Match the tool to the audience.
pcall contains expected failures
pcall runs a function in protected mode. It returns true plus results on success. It returns false plus an error on failure. The script continues either way. Branch on the boolean and handle the fallback in one place.
Put this Script in ServerScriptService.
luau
--!strict
local function parseCount(raw: string): number
local n = tonumber(raw)
assert(n ~= nil, "count must be numeric")
return n :: number
end
local ok, value = pcall(parseCount, "12")
print(ok)
print(if ok then value else 0)
local ok2, _ = pcall(parseCount, "ten")
print(ok2)txt
true
12
falseThe numeric string parses and prints 12. The word fails its assert and reports false. The script survives both. Use pcall at boundaries: player input, DataStores, plugin calls, and module loads that may legitimately fail. Broader pcall use appears in Advanced Functions.
Do not wrap whole systems in one pcall to hide bugs. Narrow the protected call to the risky step. Log the error with context. Return a safe fallback the caller already understands.
xpcall adds a handler with context
xpcall works like pcall but routes the error through your handler first. The handler receives the raw error and returns a richer message. It preserves more trace info for debugging. It suits cases when a plain message hides where the failure started.
Put this Script in ServerScriptService.
luau
--!strict
local function boom(): number
error("engine blew")
end
local function tag(err: string): string
return `shop failed: {err}`
end
local ok, msg = xpcall(boom, tag)
print(ok)
print(msg:find("shop failed") ~= nil)txt
false
trueThe call fails. Your handler prefixes context. The final check proves the tag survived. Log msg with the player id, item id, and retry count in real shops. That context shows which step failed.
Simple input parsing does not need xpcall. Prefer pcall there because the fallback is obvious. Save xpcall for multi-step transactions where the step name matters.
or-defaults fill gaps fast
The or operator returns its left side when truthy, else its right side. Only nil and false are falsy. Zero and empty strings are truthy. It suits quick defaults on possibly nil values.
Run this pure snippet in the Command Bar.
luau
--!strict
local function greet(name: string?): string
local who = name or "guest"
return `hi {who}`
end
print(greet("Mara"))
print(greet(nil))txt
hi Mara
hi guestA real name survives. Nil falls back to "guest". The parameter type string? means string or nil. Spell optional types this way so the checker enforces the nil handling. The syntax tour defines the shorthand.
or cannot distinguish false from nil. A false setting falls through to the default, which surprises feature flags. Use an explicit nil check when false is meaningful.
Run this pure snippet in the Command Bar.
luau
--!strict
local function withSound(enabled: boolean?): string
local on = if enabled == nil then true else enabled
return if on then "sound on" else "sound off"
end
print(withSound(nil))
print(withSound(false))txt
sound on
sound offNil takes the default true. Explicit false stays off. Apply this pattern to every boolean option players or configs can set. Quick or stays reserved for strings, tables, and numbers where false never appears.
Optional values need explicit checks
Model absence with nil. Check with == nil or ~= nil. Narrow once, then use the value confidently. Luau's checker understands the guard and refines the type after it.
Run this pure snippet in the Command Bar.
luau
--!strict
local function first(list: { string }): string?
if #list == 0 then
return nil
end
return list[1]
end
local got = first({})
print(got == nil)
local got2 = first({"sword"})
print(if got2 ~= nil then got2 else "empty")txt
true
swordEmpty input returns nil and the check prints true. Real input returns the item and the if expression unwraps it. Return nil for absence, never a magic string like "none" that callers must remember.
Accessing a field on nil errors. Guard before you index. Chain guards from outer to inner for nested optionals. Flatten with early returns so the happy path stays left-aligned.
Validation runs permission, type, shape, value, rate
Validate in a fixed order so every reader knows where each check lives. Check permission and context first. Then type. Then structure. Then value ranges. Then rate limits. Return silently on permission failures to avoid leaking info. Error or fallback on programmer-facing breaks.
Put this ModuleScript in ReplicatedStorage and name it Buys.
luau
--!strict
local Buys = {}
function Buys.priceOf(item: any): number
assert(type(item) == "table", "item must be a table")
assert(type(item.price) == "number", "item.price must be a number")
if item.price < 0 or item.price > 10000 then
error("item.price past bounds", 2)
end
return math.floor(item.price)
end
return BuysAssert the table shape first, then the field type, then the value range. Each layer assumes the prior layer passed. Callers get one clear failure instead of a mysterious metamethod error three calls deep.
Put this Script in ServerScriptService.
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Buys = require(ReplicatedStorage:WaitForChild("Buys"))
local ok, price = pcall(Buys.priceOf, {price = 49.9})
print(ok)
print(if ok then price else 0)txt
true
49The valid item floors to 49. Invalid shapes return false instead of crashing the server script. Log the failing payload beside the message so you can reproduce it from output alone.
Validate at trust boundaries: remote arguments, DataStore loads, cross-module configs, and anything players type. Skip validation for locals you just built. Over-checking internal helpers adds noise without safety.
Contracts tell callers the deal
A contract is a short promise above each public function: what it takes, what it returns, what it never does, and what happens on bad input. Write it in prose, not as code comments inside the function, so readers see it in the doc page beside the code. Keep it to three lines: inputs, outputs, failure mode.
A good contract for a shop grant reads: takes a player and a positive item id, returns true plus a receipt on success, returns false plus a reason on failure, never errors on player input, never duplicates grants. Callers code to that promise without reading your body. Test each clause directly: valid grant, bad id, double grant, nil player.
State units and bounds in contracts. Coins are integers. Seconds are numbers. Indices are 1-based. Lengths use #. These details prevent the classic off-by-one and float-for-index bugs. The table and string contracts in tables and strings model the same precision.
Common mistakes
Wrong: you error at level 1 inside a helper and blame the library.
luau
--!strict
local function needPositive(n: number)
if n <= 0 then
error("need positive")
end
end
needPositive(-2)Studio highlights your guard line instead of the caller. Fix it with level 2 so the caller line gets the blame.
luau
--!strict
local function needPositive(n: number)
if n <= 0 then
error("need positive", 2)
end
endWrong: you default a boolean with or and lose false.
luau
--!strict
local function mode(flag: boolean?): boolean
return flag or true
end
print(mode(false))This prints true because false or true skips false. Fix it with an explicit nil check.
luau
--!strict
local function mode(flag: boolean?): boolean
return if flag == nil then true else flag
end
print(mode(false))
print(mode(nil))txt
false
trueWrong: you index an optional without narrowing.
luau
--!strict
local function head(list: { string }): string
local v: string? = if #list > 0 then list[1] else nil
return v
endStudio reports a type error returning string? as string. Fix it with a fallback or a nil return type.
luau
--!strict
local function head(list: { string }): string
local v: string? = if #list > 0 then list[1] else nil
return if v ~= nil then v else "empty"
end
print(head({}))txt
emptyWrong: you ignore the pcall status and use the error as data.
luau
--!strict
local function strict(n: number): number
assert(n > 0, "need positive")
return n
end
local _, v = pcall(strict, -5)
print(v + 1)v holds the error string, so the addition errors again. Fix it by branching on the boolean first.
luau
--!strict
local function strict(n: number): number
assert(n > 0, "need positive")
return n
end
local ok, v = pcall(strict, -5)
print(if ok then v else 0)txt
0Exercises
- Raise error at level 2 from a bounds helper. Show the caller line takes the blame.
- Assert a table shape, then pcall the helper with good and bad inputs. Print both outcomes.
- Fix an or-default that hides false. Prove nil takes the default while false survives.
- Validate a shop item in permission, type, shape, value order. Return floored prices for good items and safe fallbacks for bad ones.
Solution 1
Put this Script in ServerScriptService.
luau
--!strict
local function at(list: { string }, i: number): string
if i < 1 or i > #list then
error(`slot {i} past bounds`, 2)
end
return list[i]
end
print(at({"a", "b"}, 2))
local ok, _ = pcall(at, {"a", "b"}, 9)
print(ok)txt
b
falseValid reads return the item. Out-of-range reads fail at the call site thanks to level 2.
Solution 2
Run this pure snippet in the Command Bar.
luau
--!strict
local function total(cart: any): number
assert(type(cart) == "table", "cart must be a table")
assert(type(cart.n) == "number", "cart.n must be a number")
return cart.n * 2
end
local ok, v = pcall(total, {n = 5})
print(ok)
print(if ok then v else 0)
local ok2, _ = pcall(total, {n = "x"})
print(ok2)txt
true
10
falseShape asserts pass for the good cart and fail for the bad one. The script continues in both cases.
Solution 3
Run this pure snippet in the Command Bar.
luau
--!strict
local function alerts(on: boolean?): boolean
return if on == nil then true else on
end
print(alerts(nil))
print(alerts(false))
print(alerts(true))txt
true
false
trueNil takes the default. Explicit flags survive untouched. Plain or would have flipped false to true.
Solution 4
Put this ModuleScript in ReplicatedStorage and name it ShopCheck.
luau
--!strict
local ShopCheck = {}
function ShopCheck.charge(item: any): (boolean, number)
if type(item) ~= "table" then
return false, 0
end
if type(item.price) ~= "number" then
return false, 0
end
if item.price < 0 or item.price > 5000 then
return false, 0
end
return true, math.floor(item.price)
end
return ShopCheckPut this Script in ServerScriptService.
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ShopCheck = require(ReplicatedStorage:WaitForChild("ShopCheck"))
local ok, price = ShopCheck.charge({price = 19.9})
print(ok)
print(price)
local ok2, fallback = ShopCheck.charge({price = -5})
print(ok2)
print(fallback)txt
true
19
false
0Type checks precede range checks. Good items floor cleanly. Bad items return a safe tuple the caller already handles.
Summary
- error with levels blames the right line. assert guards programmer contracts and passes values through.
- pcall and xpcall contain expected failures. Branch on their booleans.
- or-defaults suit nil gaps, never meaningful false. Model absence with nil and narrow explicitly.
- Validate permission, type, shape, value, then rate. Write short contracts callers can test.
- Next: Datamodel and Instances.