TypeScript lesson 9 of 9
Type Narrowing and Guards
How TypeScript narrows a union type across an if, the in operator, and a discriminated union's shared property, and how to write your own type predicate function to teach the compiler a new check.
Published · Every example on this page was run before it was published.
A union type restricts what you can do with a value until the compiler can prove exactly which member
of the union it is looking at. The previous lessons used typeof for that proof on plain primitives.
This lesson goes further: narrowing across object shapes, the in operator, and functions you write
yourself that teach the compiler an entirely new fact about a value.
Recap: typeof Narrowing
A quick reminder of the pattern from the union types lesson, on a slightly different pair of types:
function display(value: string | boolean): string {
if (typeof value === "boolean") {
return value ? "yes" : "no";
}
return value;
}
console.log(display(true));
console.log(display("maybe"));yes
maybeInside the if, value is narrowed to boolean; after it, the only possibility left is string, so
the final return value; needs no further check.
Narrowing Across Object Shapes: Discriminated Unions
typeof only distinguishes primitives from each other - it cannot tell two object types apart, since
typeof on any object is simply "object". For a union of object types, TypeScript instead narrows on
a shared property whose value differs between them:
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
interface Square {
kind: "square";
side: number;
}
function area(shape: Rectangle | Square): number {
return shape.width * shape.height;
}main.ts(13,16): error TS2339: Property 'width' does not exist on type 'Rectangle | Square'.
Property 'width' does not exist on type 'Square'.
main.ts(13,30): error TS2339: Property 'height' does not exist on type 'Rectangle | Square'.
Property 'height' does not exist on type 'Square'.shape might be a Square, and Square has no width property at all - accessing it without first
ruling Square out is rejected, the same way an unchecked union member was rejected in the previous
lesson. Checking the kind property - present on both interfaces, but holding a different literal value
on each - fixes it:
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
interface Square {
kind: "square";
side: number;
}
function area(shape: Rectangle | Square): number {
if (shape.kind === "rectangle") {
return shape.width * shape.height;
}
return shape.side * shape.side;
}
const window1: Rectangle = { kind: "rectangle", width: 4, height: 3 };
const tile: Square = { kind: "square", side: 5 };
console.log(area(window1));
console.log(area(tile));12
25Inside if (shape.kind === "rectangle"), TypeScript narrows shape all the way down to Rectangle,
because "rectangle" is a value only Rectangle's kind property can ever hold. A shared literal
property used this way is called a discriminant, and a union built around one is called a
discriminated union.
The in Operator
When there is no shared discriminant property, checking whether a specific property exists at all is another way to narrow:
interface Rectangle {
width: number;
height: number;
}
interface Square {
side: number;
}
function area(shape: Rectangle | Square): number {
if ("width" in shape) {
return shape.width * shape.height;
}
return shape.side * shape.side;
}
console.log(area({ width: 4, height: 3 }));
console.log(area({ side: 5 }));12
25"width" in shape is enough for TypeScript to know that inside the if, shape must be the interface
that actually has a width property - Rectangle - without either interface needing a discriminant
property of its own.
Custom Type Guards
Sometimes the check you need is more involved than a single property comparison - checking that every
element of an array is a number, say. A function can perform an arbitrarily complex check and still
narrow the value for the code that calls it, as long as its return type is written as a type
predicate: value is Type, instead of a plain boolean.
function isNumberArray(value: unknown): value is number[] {
return Array.isArray(value) && value.every((item) => typeof item === "number");
}
function sumAll(value: unknown): number {
if (isNumberArray(value)) {
return value.reduce((total, n) => total + n, 0);
}
return 0;
}
console.log(sumAll([1, 2, 3]));
console.log(sumAll("not an array"));6
0value: unknown accepts literally anything, the way any does, but unknown will not let you call a
method or read a property on it until you have proven what it actually is - which is exactly what
isNumberArray does. Inside if (isNumberArray(value)), TypeScript trusts the value is number[]
predicate and narrows value to number[], which is what makes .reduce(...) valid on the very next
line.
Common Mistakes
Writing a check that never actually distinguishes the union's members. typeof shape === "object"
is true for both a Rectangle and a Square - it rules nothing out, so the compiler gains nothing from
it and still refuses to let you access a property only one of the two has. Narrowing has to check
something that genuinely differs between the members, such as a discriminant property or the result of
in.
Forgetting the value is Type return type on a guard function, and getting a plain boolean
instead. Without it, calling the function still returns true or false correctly, but TypeScript
gets no information from that result about what the checked value's type actually is - the value stays
exactly as wide as it was before the call, everywhere the function is used:
function isNumberArrayPlain(value: unknown): boolean {
return Array.isArray(value) && value.every((item) => typeof item === "number");
}
function sumAll(value: unknown): number {
if (isNumberArrayPlain(value)) {
return value.reduce((total, n) => total + n, 0);
}
return 0;
}main.ts(7,12): error TS18046: 'value' is of type 'unknown'.
main.ts(7,26): error TS7006: Parameter 'total' implicitly has an 'any' type.
main.ts(7,33): error TS7006: Parameter 'n' implicitly has an 'any' type.The second and third errors are knock-on effects of the first: with value still unknown, the compiler
has no element type to give total and n, so it reports them too. The check inside isNumberArrayPlain is identical to the working version above - only its return type
changed, from value is number[] to boolean. That one difference is the whole reason the second
version narrows nothing: value is number[] is a promise about the argument, visible to every caller;
a plain boolean is just a true-or-false result, with no connection back to the type of anything.
Assuming narrowing survives being handed off to something else. A narrowed type only holds for as long as nothing could have changed the value in between. Reassigning the narrowed variable to something wider, or passing it into a separate function that returns a differently-typed value and reassigning the result back, both give the compiler a reason to widen the type again - narrowing is a property of a specific stretch of code, not a permanent fact stapled onto the variable.
Next Steps
balanced-parentheses narrows on individual characters (an opening bracket versus a closing one, versus everything else), and nth-fibonacci-memoized narrows on whether a value has already been computed and cached - both are natural places to put discriminant-style and custom-guard narrowing to use.
In the TypeScript playground, define two interfaces that share one
discriminant property, write a function that takes their union and narrows on it, and then write your
own value is Type guard function for something unrelated. Write the guard's return type as plain
boolean first, on purpose, and see exactly where narrowing stops working before switching it back to
a type predicate.
When you want a harder one
Interview-style problems graded against hidden tests — a big step up from the exercises. Come back to these when the ideas in this lesson feel comfortable rather than new.