Command Palette

Search for a command to run...

Hectal
PHASE 4Beginner ~28 min· topic 7 of 7

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 a true result 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.

pets.tswhole filets
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.

user.tswhole filets
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.

Assertions: throw or continuediagram
Rendering diagram…

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. 1

    Fix the lying guard

    In the last example change the body of isString to return typeof x === "string";. Run it. show(3) now prints 3.0.

  2. 2

    Predict the inference

    In the second example change s !== undefined to !!s. Predict the type of strs before 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. 3

    Make the assertion an arrow function

    In the third example, rewrite assertIsString as const 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

A user-defined guard narrows both branches

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
woof
Inferred type predicates in filter TypeScript 5.5+

Since 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 ]
Assertion functions throw or narrow TypeScript 3.7+

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 number
Guards that lie

No 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 function

Break 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 => { ... };.

terminal
$ Types panel
── what you'll see ──
index.ts(10,3): error TS2775: Assertions require every name in the call target to be declared with an explicit type annotation.
index.ts(11,10): error TS18046: 'input' is of type 'unknown'.

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 T requires T to be assignable to the declared type of x), otherwise the compiler reports an error. The false branch is computed by filtering the union to the members not assignable to T. When the argument isn't a union, the false branch can become never, which is why a guard on a non-union type can make the else branch 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 T predicates in class methods narrow this, and asserts this is T narrows 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. 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 written x is T. It returns a normal boolean; the special return type tells the compiler: "when this function returns true, the argument x is a T; when it returns false, it is not a T".

  2. 2

    Example: function isCat(pet: Cat | Dog): pet is Cat { return "meow" in pet; }. Now if (isCat(pet)) narrows pet to Cat, and the else narrows it to Dog. The body can be anything: a typeof, an in, several checks together. The benefit is that the check is named, reusable and in one place. Array methods use them: pets.filter(isCat) returns a Cat[], not a (Cat | Dog)[].

  3. 3

    Guards for unknown data. Data from outside (JSON.parse, a server, localStorage) has type unknown or any. A guard isUser(x: unknown): x is User checks the shape at runtime, one property at a time (typeof x === "object", not null, "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. 4

    Inferred type predicates (TypeScript 5.5). Before 5.5, mixed.filter(s => s !== undefined) still produced an array that may contain undefined; you had to write the x is string annotation. 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 !== undefined qualifies; Boolean and (n) => n do not (zero is falsy, so "false" doesn't mean "not a number").

  5. 5

    Assertion functions. A guard returns true or false. An assertion function either returns normally or throws. Two forms: asserts value is string (after a normal return, value is a string) and asserts condition (after a normal return, the condition is true, like assert(text !== undefined)). Node's assert has this signature. They're ideal for "stop with a clear error if this isn't right" at the top of a function.

  6. 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 const without 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 the const a function type yourself.

  7. 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 every if (isString(v)) now narrows numbers to strings. A guard can also lie in the false direction: a predicate s is string that returns false for the empty string tells the compiler that "" is *not a string*, so the else branch is wrong. Keep guards small, test them, and make them return true exactly when the type holds.

Explain it without notes

01

What is a type predicate and how is it different from returning a plain boolean?

02

How do assertion functions differ from type guards and when would you use one?

03

What changed in TypeScript 5.5 for array.filter?

04

Why are custom type guards a soundness risk?

Practice

01

Write function isNumberArray(x: unknown): x is number[] that really checks every item, then use it on JSON.parse("[1,2,3]").

02

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.

03

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 T return type.

  • I can use a guard with filter to change an array's item type.

  • I can explain inferred type predicates and when they are not inferred.

  • I can write asserts x is T and asserts condition functions.

  • I can describe two ways a guard can lie, and how to avoid them.