TypeScript Type Narrowing and Type Guards: Complete Guide

A deep dive into TypeScript's control flow analysis — how the compiler narrows a union to a specific member, and how to teach it about checks it cannot infer on its own using type predicates and assertion functions.

Narrowing is the mechanism that makes union types usable. A value typed string | number supports almost no operations, because only members valid for both are allowed. After an if (typeof x === "string") check, however, the compiler treats x as a string inside that branch.

Understanding narrowing is what lets you replace type assertions and any escapes with checks the compiler can actually verify — which means the types in your codebase reflect reality rather than wishful thinking.

typeof, instanceof, in and Truthiness

TypeScript recognises the ordinary JavaScript checks you already write and narrows types accordingly. No special syntax is required for these.

TypeScript
The four narrowing checks the compiler understands natively.
// 1. typeof — narrows primitives
function formatId(id: string | number): string {
  if (typeof id === "string") {
    return id.toUpperCase();   // id: string
  }
  return id.toFixed(0);        // id: number
}

// 2. instanceof — narrows class instances
function describe(value: Date | Error) {
  if (value instanceof Date) {
    return value.toISOString(); // value: Date
  }
  return value.message;         // value: Error
}

// 3. in — narrows by property presence
interface Bird { fly(): void }
interface Fish { swim(): void }

function move(animal: Bird | Fish) {
  if ("fly" in animal) {
    animal.fly();   // animal: Bird
  } else {
    animal.swim();  // animal: Fish
  }
}

// 4. Truthiness — narrows away null and undefined
function greet(name?: string) {
  if (!name) return "Hello, guest";
  return `Hello, ${name.trim()}`;  // name: string
}
Note: Truthiness checks narrow away more than just null and undefined — an empty string and the number zero are also falsy. When zero is a valid value, test value !== undefined explicitly rather than relying on truthiness.
TypeScript
A truthiness check that silently discards a valid zero.
function setVolume(level?: number) {
  // Bug: level === 0 falls into the default branch
  if (!level) return 50;
  return level;
}

function setVolumeFixed(level?: number) {
  // Correct: only undefined triggers the default
  if (level === undefined) return 50;
  return level;
}

// Or use nullish coalescing, which only tests null/undefined
const volume = (level?: number) => level ?? 50;

The typeof null Pitfall

typeof null returns "object" in JavaScript, a long-standing quirk the compiler models faithfully. A typeof value === "object" check therefore does not exclude null.

TypeScript
typeof "object" still includes null.
function process(value: string[] | null) {
  if (typeof value === "object") {
    // value: string[] | null — null is NOT excluded
    // Error: 'value' is possibly 'null'.
    // console.log(value.length);
  }

  if (value !== null) {
    console.log(value.length);  // value: string[]
  }
}

The Most Reliable Narrowing Pattern

A discriminated union gives every member a shared literal property — the discriminant — that uniquely identifies it. Checking that one property narrows the whole object, which makes this the most robust narrowing pattern available.

TypeScript
A discriminated union narrowed by its tag property.
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;    // radius available
    case "rectangle":
      return shape.width * shape.height;      // width/height available
    case "triangle":
      return (shape.base * shape.height) / 2;
  }
}

This pattern scales to request state, reducer actions, and API results. Compare it with the common alternative of one interface with many optional fields, which forces defensive checks everywhere and cannot express that radius and width are mutually exclusive.

TypeScript
Modelling async state as a discriminated union.
type RequestState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

function render(state: RequestState<string[]>) {
  switch (state.status) {
    case "idle":    return "Ready";
    case "loading": return "Loading...";
    case "success": return state.data.join(", ");  // data only here
    case "error":   return state.error.message;    // error only here
  }
}

// The illegal combination is now unrepresentable:
// you cannot have status "loading" with a data field.

Making the Compiler Catch Unhandled Cases

Once every member of a union has been handled, the narrowed type in the remaining branch is never. Assigning to a never variable in a default case turns a forgotten union member into a compile error.

TypeScript
An exhaustiveness guard that fails the build when a case is missed.
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number };

function assertNever(value: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    default:
      // All members handled, so `shape` is `never` here.
      return assertNever(shape);
  }
}

// Add a third member to Shape and the default branch errors:
// Argument of type '{ kind: "triangle"; ... }' is not
// assignable to parameter of type 'never'.

This is one of the highest-value patterns in TypeScript. It converts the open-ended question "did we update every switch when we added that variant?" into a list of compiler errors pointing at exactly the code that needs changing.

Type Predicates with the is Keyword

When a check is too complex for the compiler to follow, a type predicate lets you declare what a boolean-returning function proves. The return type value is Type tells the compiler to narrow at every call site.

TypeScript
A user-defined type guard validating an unknown value.
interface User {
  id: number;
  email: string;
}

// Without a predicate this returns plain `boolean` and narrows nothing
function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    typeof (value as User).id === "number" &&
    "email" in value &&
    typeof (value as User).email === "string"
  );
}

async function loadUser(raw: unknown) {
  if (!isUser(raw)) {
    throw new Error("Malformed user payload");
  }
  // raw: User — safe to use without a cast
  console.log(raw.email.toLowerCase());
}
Note: A type predicate is an assertion the compiler trusts without verifying. If the body's logic does not actually prove the claimed type, you have reintroduced the unsoundness of a cast — the guard is only as good as its implementation.

Filtering Arrays with a Guard

A common use is narrowing an array. A plain Boolean filter does not change the element type, but a type predicate does.

TypeScript
Removing nulls from an array in a way the compiler understands.
const maybeNames: (string | null)[] = ["Ada", null, "Grace", null];

// filter(Boolean) does not narrow: still (string | null)[]
const a = maybeNames.filter(Boolean);

// A type predicate narrows the element type properly
function isNotNull<T>(value: T | null): value is T {
  return value !== null;
}

const names = maybeNames.filter(isNotNull);  // string[]
console.log(names.map((n) => n.toUpperCase()));

Assertion Functions

An assertion function uses the asserts modifier instead of returning a boolean. It narrows the type for all code following the call, which suits validation at the top of a function.

TypeScript
Assertion signatures narrow for the remainder of the scope.
function assertDefined<T>(
  value: T | null | undefined,
  label: string
): asserts value is T {
  if (value === null || value === undefined) {
    throw new Error(`${label} is required`);
  }
}

function sendEmail(user: { email?: string }) {
  assertDefined(user.email, "email");
  // user.email: string for the rest of the function
  console.log(user.email.trim());
}
Note: Assertion functions must have an explicit type annotation on the variable or parameter holding them. TypeScript will not apply an asserts signature through an inferred function reference.

Callbacks, Mutation and Re-widening

Control flow analysis is scoped. A narrowing established in one place can be invalidated by a function call or a closure boundary, which accounts for most "but I already checked that" errors.

TypeScript
Narrowing does not survive into a callback over a mutable property.
interface State {
  user?: { name: string };
}

function run(state: State) {
  if (!state.user) return;

  // Narrowed here: state.user is { name: string }
  console.log(state.user.name);

  setTimeout(() => {
    // Error: 'state.user' is possibly 'undefined'.
    // The callback runs later; `state.user` may have changed.
    // console.log(state.user.name);
  });

  // Fix: copy to a local const, which cannot be reassigned
  const user = state.user;
  setTimeout(() => console.log(user.name));  // fine
}

The general remedy is to capture narrowed values into a const local. Because a const cannot be reassigned and is not a mutable property of some other object, the compiler can keep the narrowing across scope boundaries.

• Prefer discriminated unions over interfaces full of optional fields.
• Add an assertNever default branch so new union members break the build.
• Use a type predicate rather than a cast when validating unknown input.
• Copy narrowed object properties into const locals before using them in callbacks.
• Remember typeof null is "object" and that 0 and "" are falsy.

Summary

Type narrowing is what turns union types from a constraint into a tool. The compiler already follows typeof, instanceof, in and truthiness checks, and discriminated unions make narrowing explicit and reliable across large codebases.

Where the compiler cannot infer a check, type predicates and assertion functions let you extend narrowing to your own validation logic — with the caveat that the compiler trusts these assertions rather than verifying them.

Combine discriminated unions with an assertNever exhaustiveness guard and the type system starts doing real work for you: adding a new variant produces a precise list of every site that must be updated, before any of it reaches production.