Appearance
Casts and Assertions
Written: 2026-10-14
Learning objectives
- You cast only between related types.
- You keep casts to one value.
- You reserve any-casts for untyped package edges.
- You spot pack truncation in calls.
- You prefer remodel, guard, or parse over casting.
Prerequisites
Casts move between related shapes
The checker knows almost enough. You point it at the narrower truth.
The precise rule is bidirectional subtyping. value :: Target passes when either side is a subtype of the other. Unrelated pairs still fail. This bidirectional rule is defined in syntax type ascription bidi. The luau.org types guide states the same check and shows unrelated casts failing. The type checking guide shows any as the universal escape and unrelated pairs as errors.
A downcast can lie. The checker trusts you at that line. Runtime still holds the old value. A wrong downcast crashes later.
The consequence is restraint. You cast one value at one drifted edge. You prove the rest with annotations and guards from Narrowing and Guards.
Put this Script in ServerScriptService.
luau
--!strict
local base = Instance.new("Part")
local asInstance: Instance = base :: Instance
local backToPart = asInstance :: PartThis block checks clean. You infer asInstance as Instance and backToPart as Part. Part to Instance is an upcast. Instance to Part is a downcast. Both directions relate, so both pass.
Put this Script in ServerScriptService.
luau
--!strict
local coins: number = 7
local text = coins :: stringThis block errors. number and string are unrelated. The checker rejects the conversion. You fix it with tostring(coins), which remodels instead of casting.
Narrow casts stay on one value
You touch the uncertain value. You leave neighbors alone.
:: applies to the expression on its left. It does not widen or narrow the whole call. Parentheses set exactly which value you mean.
The limitation is packs. A cast on a multi-return call keeps only the first value and drops the rest. The luau.org types guide documents this truncation. That behavior surprises forwarding wrappers.
You wrap the value you mean. You never cast a trailing multi-return inside another call unless you intend to drop values.
Put this Script in ServerScriptService.
luau
--!strict
local function drops(): (number, string)
return 4, "gem"
end
local first = drops() :: numberThis block checks clean. You infer first as number. The cast targets the first return. The second return is discarded by the single local, not by the cast alone.
Put this Script in ServerScriptService.
luau
--!strict
local function drops(): (number, string)
return 4, "gem"
end
local function take(a: number, b: string)
end
take(1, drops() :: any)This block checks clean but passes only one value where two are wanted. You infer the call as arity-mismatched at runtime even though the checker stays quiet. The cast truncates the pack. You fix forwarding with a typed wrapper from Type Packs and Variadics, not with a cast inside the call.
Any-casts live only at untyped edges
Most code has types. One package does not. You contain the untyped call.
The precise rule is boundary-only any. Every value can cast to any and any can flow anywhere with suppressed errors. The type checking guide shows number :: any passing. Error suppression from any is defined in type error suppression. You use that power only where a package ships no Luau types.
An any return infects later code. The checker stops helping downstream. One convenience cast can silence a whole file.
You cast the package result, validate it with guards, and store a precise type. Later code never sees any.
Put this ModuleScript in ServerScriptService. It stands in for an untyped package edge.
luau
--!strict
local function fetchRaw(player: Player): any
return { coins = 12 }
end
local function coinsFor(player: Player): number
local raw = fetchRaw(player)
if typeof(raw) == "table" then
local coins = (raw :: { coins: number }).coins
if typeof(coins) == "number" then
return coins
end
end
return 0
endThis block checks clean. You infer coinsFor as (Player) -> number. The any appears once at the untyped call. Guards and a narrow table cast turn it into number before return. Callers stay fully checked.
Put this Script in ServerScriptService.
luau
--!strict
local coins: number = 5
local leaked = coins :: any
local doubled = leaked * 2This block checks clean but leaks any into doubled. You infer doubled as any. Later misuse of doubled passes silently. You fix it by removing the cast and keeping coins as number.
Safe alternatives remove the need to cast
A cast patches the symptom. Remodel, guard, or parse cures the shape.
The precise rule is three options. You remodel the value with a constructor like tostring, tonumber plus a guard, or a new table. You guard the existing value with typeof, ~= nil, or assert. You parse unknown input into a known record before use. Guards are covered in Narrowing and Guards.
Each option needs a few more lines. A cast needs one operator. The extra lines are the proof future readers need.
Boundaries get parsers. Internals stay precise. Casts remain for Engine drift only, where the runtime call is right and the declared type is stale.
Put this Script in ServerScriptService.
luau
--!strict
local function total(text: string): number
local value = tonumber(text)
if value ~= nil then
return value
end
return 0
end
local got = total("12")This block checks clean. You infer got as number. tonumber returns number?. The nil guard refines it to number. No cast appears. Invalid text maps to zero through an explicit fallback.
Put this Script in ServerScriptService.
luau
--!strict
export type CoinBox = { coins: number }
local function asBox(raw: unknown): CoinBox?
if typeof(raw) == "table" then
local coins = (raw :: CoinBox).coins
if typeof(coins) == "number" then
return { coins = coins }
end
end
return nil
end
local got = asBox({ coins = 3 })This block checks clean. You infer got as CoinBox?. The narrow cast reads one field from a guarded table. The function returns a fresh record with the proven shape. Callers branch on nil instead of casting again.
Common mistakes
Wrong: you cast unrelated types.
Put this Script in ServerScriptService.
luau
--!strict
local count: number = 4
local name = count :: stringThis block errors. The pair has no subtype relation. You fix it by remodeling with a constructor.
luau
--!strict
local count: number = 4
local name = tostring(count)This fix checks clean. You infer name as string.
Wrong: you blanket a file with any.
Put this Script in ServerScriptService.
luau
--!strict
local save: any = { coins = 10 }
local out = save.coins + "bonus"This block may pass the checker while failing at runtime. any suppresses the field and arithmetic checks. You fix it by typing the save shape and guarding the read.
luau
--!strict
export type Save = { coins: number }
local save: Save = { coins = 10 }
local out = save.coins + 5This fix checks clean. You infer out as number.
Wrong: you cast a pack inside a call and lose values.
Put this Script in ServerScriptService.
luau
--!strict
local function pair(): (number, string)
return 1, "gem"
end
local function take(a: number, b: string)
end
take(pair() :: any)This block loses the second value through truncation. You fix it by binding both returns first, then passing them.
luau
--!strict
local function pair(): (number, string)
return 1, "gem"
end
local function take(a: number, b: string)
end
local a, b = pair()
take(a, b)This fix checks clean. You infer a as number and b as string. Both values survive.
Exercises
- Upcast and downcast between Part and Instance.
- Show an unrelated cast failing and remodel it.
- Isolate an any-returning edge with guards.
- Bind a two-return call before forwarding it.
- Replace a cast with a parser that returns an optional.
Solution 1
Put this Script in ServerScriptService.
luau
--!strict
local part = Instance.new("Part")
local asInstance: Instance = part :: Instance
local asPart = asInstance :: PartThis block checks clean. You infer asPart as Part. Both casts relate, so both pass. A cast to string would fail as unrelated.
Solution 2
Put this Script in ServerScriptService.
luau
--!strict
local level: number = 3
local label = tostring(level)This block checks clean. You infer label as string. level :: string would error. tostring rebuilds the value in the wanted type.
Solution 3
Put this Script in ServerScriptService.
luau
--!strict
local function rawSave(): any
return { coins = 8 }
end
local function loadCoins(): number
local raw = rawSave()
if typeof(raw) == "table" then
local coins = (raw :: { coins: number }).coins
if typeof(coins) == "number" then
return coins
end
end
return 0
end
local got = loadCoins()This block checks clean. You infer got as number. any never leaves the loader. Guards prove the table and the number before return.
Solution 4
Put this Script in ServerScriptService.
luau
--!strict
local function stats(): (number, string)
return 9, "Mara"
end
local function show(score: number, name: string)
end
local score, name = stats()
show(score, name)This block checks clean. You infer score as number and name as string. Binding first preserves the pack. Casting inside the call would truncate it.
Solution 5
Put this Script in ServerScriptService.
luau
--!strict
export type Award = { gems: number }
local function parse(raw: unknown): Award?
if typeof(raw) == "table" then
local gems = (raw :: Award).gems
if typeof(gems) == "number" then
return { gems = gems }
end
end
return nil
end
local got = parse({ gems = 2 })This block checks clean. You infer got as Award?. The parser returns a fresh record or nil. Callers guard nil instead of casting.
Summary
- You cast only between related types in either direction.
- You cast one value with clear parentheses.
- Any-casts stay at untyped package edges and get parsed at once.
- Casts on packs truncate, so you bind multi-returns first.
- You prefer remodel, guard, and parse over casting.
- Next: Generics.