Skip to content

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:

TypeScript
function display(value: string | boolean): string {
  if (typeof value === "boolean") {
    return value ? "yes" : "no";
  }
  return value;
}

console.log(display(true));
console.log(display("maybe"));
Output
yes
maybe

Inside 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:

TypeScript
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;
}
Output
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:

TypeScript
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));
Output
12
25

Inside 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:

TypeScript
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 }));
Output
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.

TypeScript
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"));
Output
6
0

value: 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:

TypeScript
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;
}
Output
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.