Topic 4.7
Custom Type Guards and Assertion Functions
In one line
When the built-in checks aren't enough, you write your own: a function returning x is T tells TypeScript what a true result means, and asserts x is T does the same for functions that throw. The compiler trusts you, so they must tell the truth.
Think of it like this
A club bouncer checks your ID and stamps your hand. From then on, the staff inside trust the stamp and don't check again. A type guard is the stamp: the check happens once in one function, and everywhere after it, the compiler treats the value as the proven type. But a bouncer who stamps the wrong people spoils everything inside, and the compiler can't check the bouncer. You must.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Type predicate
- A return type of the form
x is T, which tells the compiler what atrueresult means. - Type guard
- Any check the compiler can use to narrow, including functions with a type predicate.
- Assertion function
- A function with
asserts ...return type: it returns normally if the claim is true and throws otherwise. - unknown
- The type for data of unknown shape. You must narrow it (for example with a guard) before using it.
- Runtime validation
- Checking at run time that data really has the expected shape, because types disappear after compiling.
- If and only if
- A rule that works in both directions: true exactly when the condition holds, false exactly when it doesn't.
- Soundness
- A type system is sound when its types never disagree with what happens at runtime. A guard with a wrong body makes the types lie, so it breaks soundness.
Step by step
01Name the check once
In Topic 4.4 we wrote "swim" in pet at each use. If the test is more complex or used in many places, put it in a function.
The return type pet is Cat is the whole trick. The function returns a boolean at runtime; at compile time, it narrows the argument at every call site.
function isCat(pet: Cat | Dog): pet is Cat {
return "meow" in pet;
}
if (isCat(pet)) {
pet.meow(); // pet: Cat
} else {
pet.bark(); // pet: Dog
}02Check unknown data one property at a time
JSON.parse returns any (a type that switches checking off), so we annotate the result as unknown and write a guard. Each step narrows: typeof x === "object", then not null, then "name" in x, then the type of x.name.
If all checks pass, the function returns true and the caller's value is a User. The guard is the only place that knows how a User looks at runtime.
type User = { name: string; age: number };
function isUser(x: unknown): x is User {
return (
typeof x === "object" &&
x !== null &&
"name" in x &&
typeof x.name === "string" &&
"age" in x &&
typeof x.age === "number"
);
}
const input: unknown = JSON.parse('{"name":"Asha","age":30}');
if (isUser(input)) {
console.log(input.name.toUpperCase()); // ASHA
}03filter and inferred predicates
array.filter(isCat) uses a guard to change the item type. Since TypeScript 5.5 the arrow function (s) => s !== undefined is enough: the compiler infers s is string.
The inference only happens when the answer "false" really means "not that type". (n) => n for numbers fails that test, since 0 is falsy but is a number.
04Assertions: throw or continue
An asserts value is string function is a gate. If execution continues past the call, the compiler narrows value for the rest of the scope. Use them for preconditions at the top of a function.
assert(condition) with asserts condition is the same gate with a boolean: after the call, TypeScript knows the condition is true.
05Who checks the checker?
Nobody. The compiler believes your predicate. A wrong body gives wrong types everywhere the guard is used. Test guards like any other code, write them as small as possible, and prefer built-in checks or discriminated unions when they work.
Try it yourself
- 1
Fix the lying guard
In the last example change the body of
isStringtoreturn typeof x === "string";. Run it.show(3)now prints3.0. - 2
Predict the inference
In the second example change
s !== undefinedto!!s. Predict the type ofstrsbefore you run. It stays(string | undefined)[]: an empty string is falsy but is still a string, so the compiler can't infer the predicate. - 3
Make the assertion an arrow function
In the third example, rewrite
assertIsStringasconst assertIsString = (value: unknown): asserts value is string => { ... };and read the errors (the Break it section shows them).Then annotate the constant with a function type to see how the annotation requirement is satisfied.
Code & diagrams
The true branch is Cat; the false branch is Dog, by elimination. filter(isCat) changes the item type of the array to Cat.
Console
(nothing was printed)Expected console output
meow
woofSince 5.5 the arrow function is understood as a guard (strs is string[]). Boolean is not a guard, so truthy still includes undefined in its type even though the values are fine. isDefined shows the hand-written guard that works in every version (the <T> makes it generic, a placeholder type that fits any item type; Topic 7.1 teaches it). The nums filter does not remove anything from the type, since 0 is falsy and a number.
Console
(nothing was printed)Expected console output
[ 'a', 'b' ] [ 'a', 'b' ] [ 'a', 'b' ] [ 1, 2 ]After assertIsString(input) returns, input is a string for the rest of shout. After assert(text !== undefined, ...) the compiler knows text is a string. The call with 42 throws, and the catch prints the error.
Console
(nothing was printed)Expected console output
HELLO
good
TypeError: Expected a string, got numberNo type errors, and a crash. isString checks for a number but promises a string, so show(3) calls toUpperCase on a number. isNonEmpty returns false for the empty string, so the compiler believes the else branch holds undefined, but len("") returns an empty string. The compiler cannot see either lie.
Console
(nothing was printed)Expected console output
"" 3
Uncaught TypeError: v.toUpperCase is not a functionBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
An assertion in an arrow function
In the third example, replace the function assertIsString declaration with const assertIsString = (value: unknown): asserts value is string => { ... };.
Myth vs fact
Myth
TypeScript checks that a type predicate's body is correct.
Fact
It does not. It only checks that the function returns a boolean. The x is T part is a promise you make; a wrong body compiles fine and produces wrong types.
Myth
A type assertion (value as User) validates the value.
Fact
as changes only what the compiler believes and checks nothing at runtime. A guard is the safe alternative because it really inspects the value.
Myth
filter(Boolean) removes undefined from the type.
Fact
Not even with inferred predicates: the values are removed at runtime, but the type still includes undefined. Use filter((x) => x !== undefined) (5.5+) or a named guard.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
A predicate's type must be assignable to the parameter's type (
x is TrequiresTto be assignable to the declared type ofx), otherwise the compiler reports an error. The false branch is computed by filtering the union to the members not assignable toT. When the argument isn't a union, the false branch can becomenever, which is why a guard on a non-union type can make theelsebranch unreachable. - ▸
Inferred predicates (5.5) are checked with the "if and only if" rule by actually narrowing the parameter in both the true and false cases of the returned expression. If the two directions don't agree, no predicate is inferred and the function simply returns
boolean. You can still write the predicate by hand when you know better than the compiler, at the cost of the risk described above. - ▸
A newer neighbour:
this is Tpredicates in class methods narrowthis, andasserts this is Tnarrows after a call. Validation libraries (Zod, Valibot) generate guards from schemas, so you write the shape once and get both the runtime check and the type; Topic 13.4 returns to checking data at the edges of an app.
Remember this
- 1
The problem this solves: The built-in checks can't recognise your own shapes, such as data that came from a server. You'd repeat a cast or the same checks everywhere. So far you've used the guards TypeScript ships with (
typeof,instanceof,in,===). A user-defined type guard (or type predicate) is your own function whose return type is writtenx is T. It returns a normalboolean; the special return type tells the compiler: "when this function returnstrue, the argumentxis aT; when it returnsfalse, it is not aT". - 2
Example:
function isCat(pet: Cat | Dog): pet is Cat { return "meow" in pet; }. Nowif (isCat(pet))narrowspettoCat, and theelsenarrows it toDog. The body can be anything: atypeof, anin, several checks together. The benefit is that the check is named, reusable and in one place. Array methods use them:pets.filter(isCat)returns aCat[], not a(Cat | Dog)[]. - 3
Guards for unknown data. Data from outside (
JSON.parse, a server,localStorage) has typeunknownorany. A guardisUser(x: unknown): x is Userchecks the shape at runtime, one property at a time (typeof x === "object", notnull,"name" in x,typeof x.name === "string"...). This is the bridge between the untrusted outside world and your trusted types. For big schemas, validation libraries generate such guards for you (a later phase). - 4
Inferred type predicates (TypeScript 5.5). Before 5.5,
mixed.filter(s => s !== undefined)still produced an array that may containundefined; you had to write thex is stringannotation. Since 5.5 the compiler infers the predicate when a function takes one parameter, returns a boolean expression, doesn't change (mutate) the parameter, and the expression is true if and only if the parameter is the narrower type.s !== undefinedqualifies;Booleanand(n) => ndo not (zero is falsy, so "false" doesn't mean "not a number"). - 5
Assertion functions. A guard returns
trueorfalse. An assertion function either returns normally or throws. Two forms:asserts value is string(after a normal return,valueis astring) andasserts condition(after a normal return, the condition is true, likeassert(text !== undefined)). Node'sasserthas this signature. They're ideal for "stop with a clear error if this isn't right" at the top of a function. - 6
A catch for assertion functions: they must be called through a name with an explicit type annotation. A function declaration works; an arrow function stored in a
constwithout a type does not (TS2775: Assertions require every name in the call target to be declared with an explicit type annotation), because the compiler needs to know the assertion before it checks the call. A function declaration is the easy choice, or give theconsta function type yourself. - 7
When guards lie. TypeScript does not check that the body matches the signature.
function isString(x: unknown): x is string { return typeof x === "number"; }compiles without a murmur, and everyif (isString(v))now narrows numbers to strings. A guard can also lie in thefalsedirection: a predicates is stringthat returnsfalsefor the empty string tells the compiler that""is *not a string*, so theelsebranch is wrong. Keep guards small, test them, and make them returntrueexactly when the type holds.
Explain it without notes
What is a type predicate and how is it different from returning a plain boolean?
How do assertion functions differ from type guards and when would you use one?
What changed in TypeScript 5.5 for array.filter?
Why are custom type guards a soundness risk?
Practice
Write function isNumberArray(x: unknown): x is number[] that really checks every item, then use it on JSON.parse("[1,2,3]").
Write function assertDefined<T>(x: T | undefined, name: string): asserts x is T and use it at the top of a function that takes string | undefined.
Write a guard isOk for type Result = { ok: true; value: number } | { ok: false; error: string }, and explain why a plain if (r.ok) is usually better.
Trade-offs
- ↔
A guard centralises a check and documents intent, at the cost of an unchecked promise. A tag-based union (Topic 4.5) gets the same narrowing from the compiler itself with nothing to maintain.
- ↔
Hand-written validators for big data shapes are long and drift from the types. Past a handful of fields, generate the guard from a schema so the type and the check come from one definition.
Done when you can
I can write a function with a
x is Treturn type.I can use a guard with
filterto change an array's item type.I can explain inferred type predicates and when they are not inferred.
I can write
asserts x is Tandasserts conditionfunctions.I can describe two ways a guard can lie, and how to avoid them.