Last updated: 2026-05-05

Type guards and narrowing

In TypeScript, Type Guards and Narrowing work together to provide safety when dealing with flexible types like unions. Narrowing is the process of refining a type, while Type Guards are the tools you use to make it happen.


Type Guards

Type guards are specific checks that allow you to tell TypeScript exactly what data type a variable is at a specific point in your code.

1. Using typeof for Primitives

The most common guard for basic types like string, number, or boolean.

function printLength(value: string | number) {
  if (typeof value === "string") {
    console.log(value.length);  // ✅ TypeScript knows it's a string
  } else {
    console.log(value.toFixed(2)); // ✅ TypeScript knows it's a number
  }
}

2. Using instanceof for Classes

Used to check if an object is an instance of a specific class.

class Bird { fly() { console.log("Flying..."); } }
class Fish { swim() { console.log("Swimming..."); } }

function move(animal: Bird | Fish) {
  if (animal instanceof Bird) {
    animal.fly(); // ✅ Narrowed to Bird
  } else {
    animal.swim(); // ✅ Narrowed to Fish
  }
}

3. Using the in Operator for Objects

Used to check if a specific property exists on an object.

type Admin = { name: string; privileges: string[] };
type User = { name: string; id: number };

function info(person: Admin | User) {
  if ("privileges" in person) {
    console.log(person.privileges); // ✅ Narrowed to Admin
  }
}

4. Custom Type Guards (is keyword)

A function that tells TypeScript how to recognize a specific type. Note that the parameter should be typed as unknown rather than anyunknown forces the check to happen before TypeScript trusts the value, which is consistent with TypeScript's safety philosophy.

function isString(value: unknown): value is string {
  return typeof value === "string";
}

function process(input: unknown) {
  if (isString(input)) {
    console.log(input.toUpperCase()); // ✅ Narrowed to string
  }
}

Narrowing

Narrowing is the "magic" that happens after a type guard. It is TypeScript's ability to refine the type of a variable based on your conditional logic.

When you use an if statement or a switch case to check a type, the type "shrinks" inside that block.

function greet(person: string | { name: string }) {
  // Before the check, person is 'string | { name: string }'
  
  if (typeof person === "string") {
    console.log("Hello, " + person);  // Narrowed: person is strictly a 'string'
  } else {
    console.log("Hello, " + person.name); // Narrowed: person is strictly the object
  }
}

Why It's Useful

  • Preventing Errors: You can't accidentally call .toUpperCase() on a number.
  • IntelliSense: Your code editor will only suggest properties that actually exist on the narrowed type.
  • Security: It forces you to handle all possible types in a union, ensuring your code doesn't crash.

Discriminated Unions

A discriminated union is a pattern where each type in a union shares a common property with a unique literal value. TypeScript uses this shared property to narrow the type automatically — making it one of the most reliable and readable narrowing patterns in real-world code.

type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; side: number };

type Shape = Circle | Square;

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2; // ✅ Narrowed to Circle
    case "square":
      return shape.side ** 2;             // ✅ Narrowed to Square
  }
}

The kind property is the discriminant — the shared key whose unique values tell TypeScript which type it is dealing with in each branch. This pattern pairs naturally with switch statements and scales well as the number of types in a union grows.


Exhaustiveness Checking with never

The never type introduced in earlier topics becomes practically useful here. When narrowing a discriminated union with a switch statement, TypeScript can detect at compile time if you have forgotten to handle a case — a pattern called exhaustiveness checking.

type Shape = Circle | Square;

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.side ** 2;
    default:
      // If all cases are handled, shape is 'never' here
      // If a new shape is added to the union but not handled above,
      // TypeScript will throw an error on this line
      const _exhaustiveCheck: never = shape;
      throw new Error(`Unhandled shape: ${_exhaustiveCheck}`);
  }
}

If you later add a new type to the Shape union — say Triangle — but forget to add a case "triangle" branch, TypeScript will immediately flag the default block as an error, because shape would no longer be assignable to never. This turns missing cases from silent runtime bugs into compile-time errors.