---
url: /strict-typing/casts-and-assertions.md
description: Convert between related types safely, and remodel instead of sprinkling any.
---

# Casts and Assertions

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

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

* [Narrowing and Guards](./narrowing-and-guards.md)
* [Annotations and Inference](./annotations-and-inference.md)

## 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](https://rfcs.luau.org/syntax-type-ascription-bidi.html). The [luau.org types](https://luau.org/types) guide states the same check and shows unrelated casts failing. The [type checking](https://create.roblox.com/docs/en-us/luau/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](./narrowing-and-guards.md).

Put this Script in ServerScriptService.

```luau
--!strict
local base = Instance.new("Part")
local asInstance: Instance = base :: Instance
local backToPart = asInstance :: Part
```

This 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 :: string
```

This 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](https://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() :: number
```

This 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](./type-packs-and-variadics.md), 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](https://create.roblox.com/docs/en-us/luau/type-checking) guide shows `number :: any` passing. Error suppression from `any` is defined in [type error suppression](https://rfcs.luau.org/type-error-suppression.html). 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
end
```

This 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 * 2
```

This 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](./narrowing-and-guards.md).

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 :: string
```

This 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 + 5
```

This 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

1. Upcast and downcast between Part and Instance.
2. Show an unrelated cast failing and remodel it.
3. Isolate an any-returning edge with guards.
4. Bind a two-return call before forwarding it.
5. Replace a cast with a parser that returns an optional.

::: details Solution 1
Put this Script in ServerScriptService.

```luau
--!strict
local part = Instance.new("Part")
local asInstance: Instance = part :: Instance
local asPart = asInstance :: Part
```

This block checks clean. You infer `asPart` as `Part`. Both casts relate, so both pass. A cast to `string` would fail as unrelated.
:::

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

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

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

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