Skip to content
Codeloom
TypeScript

TypeScript Type Guards and Narrowing Explained

Learn how TypeScript type guards and control flow narrowing work, including typeof, instanceof, in, discriminated unions, and custom type predicates.

·8 min read · By Codeloom
Intermediate 12 min read

What you'll learn

  • How TypeScript narrows types through control flow analysis
  • All built-in type guard mechanisms and when to use each
  • How to write custom type predicates for complex types
  • Discriminated union patterns for exhaustive type checking

Prerequisites

  • TypeScript basics
  • Union types and interfaces

TypeScript’s type system tracks the possible types of a variable as it flows through your code. When you write a conditional check, TypeScript narrows the type in each branch. This is called control flow analysis, and type guards are the checks that trigger narrowing.

typeof Guards

The simplest type guard. TypeScript understands typeof checks for primitives:

function formatValue(value: string | number | boolean): string {
  if (typeof value === "string") {
    return value.toUpperCase(); // value is string here
  }
  if (typeof value === "number") {
    return value.toFixed(2); // value is number here
  }
  return value ? "Yes" : "No"; // value is boolean here
}

TypeScript narrows the type in each branch. After the first if, the remaining type is number | boolean. After the second if, only boolean remains.

typeof works for: "string", "number", "boolean", "bigint", "symbol", "undefined", "object", and "function".

Watch out for null:

function process(value: string | null) {
  if (typeof value === "object") {
    // value is still string | null here!
    // typeof null === "object" is a famous JavaScript quirk
  }

  if (value !== null) {
    value.toUpperCase(); // value is string here
  }
}

Truthiness Narrowing

TypeScript narrows based on truthiness checks:

function printName(name: string | null | undefined) {
  if (name) {
    console.log(name.toUpperCase()); // name is string
  }
}

But be careful with values that are falsy but valid:

function processCount(count: number | null) {
  if (count) {
    // count is number, but 0 is excluded!
    console.log(count);
  }

  // Better: explicit null check
  if (count !== null) {
    console.log(count); // count is number, including 0
  }
}

Equality Narrowing

TypeScript narrows when you compare values with ===, !==, ==, or !=:

function example(x: string | number, y: string | boolean) {
  if (x === y) {
    // x and y must both be string (the only shared type)
    console.log(x.toUpperCase());
    console.log(y.toUpperCase());
  }
}

function checkStatus(status: "active" | "inactive" | "pending") {
  if (status !== "active") {
    // status is "inactive" | "pending"
  }
}

The == null check narrows both null and undefined:

function process(value: string | null | undefined) {
  if (value != null) {
    // value is string (both null and undefined removed)
    console.log(value.toUpperCase());
  }
}

instanceof Guards

For class instances and built-in objects:

function handleError(error: Error | string) {
  if (error instanceof Error) {
    console.log(error.message);  // error is Error
    console.log(error.stack);
  } else {
    console.log(error);  // error is string
  }
}

function processDate(input: string | Date) {
  if (input instanceof Date) {
    return input.toISOString(); // input is Date
  }
  return new Date(input).toISOString(); // input is string
}

The in Operator

Checks if a property exists on an object, narrowing the type:

interface Dog {
  bark(): void;
  breed: string;
}

interface Cat {
  meow(): void;
  indoor: boolean;
}

function handlePet(pet: Dog | Cat) {
  if ("bark" in pet) {
    pet.bark();           // pet is Dog
    console.log(pet.breed);
  } else {
    pet.meow();           // pet is Cat
    console.log(pet.indoor);
  }
}

The in operator works well when the types have distinct properties. If both types share all property names, use a discriminant instead.

Discriminated Unions

The most powerful narrowing pattern. Add a literal type property that uniquely identifies each variant:

interface Circle {
  kind: "circle";
  radius: number;
}

interface Rectangle {
  kind: "rectangle";
  width: number;
  height: number;
}

interface Triangle {
  kind: "triangle";
  base: number;
  height: number;
}

type Shape = Circle | Rectangle | Triangle;

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

Each case narrows shape to the specific variant. TypeScript knows all properties available in each branch.

Exhaustive Checking

Add a default case that catches unhandled variants at compile time:

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return (shape.base * shape.height) / 2;
    default:
      const _exhaustive: never = shape;
      throw new Error(`Unhandled shape: ${_exhaustive}`);
  }
}

If you add a new shape variant to the union but forget to handle it in the switch, the assignment to never fails at compile time. This catches missing cases before they reach production.

Custom Type Predicates

For complex type checks, write a function that returns a type predicate:

interface Fish {
  swim(): void;
  name: string;
}

interface Bird {
  fly(): void;
  name: string;
}

function isFish(pet: Fish | Bird): pet is Fish {
  return "swim" in pet;
}

function handlePet(pet: Fish | Bird) {
  if (isFish(pet)) {
    pet.swim();  // pet is Fish
  } else {
    pet.fly();   // pet is Bird
  }
}

The pet is Fish return type annotation is the type predicate. It tells TypeScript that when the function returns true, the argument is Fish.

Filtering Arrays with Type Predicates

Type predicates are essential for typed array filtering:

interface SuccessResponse {
  status: "success";
  data: unknown;
}

interface ErrorResponse {
  status: "error";
  message: string;
}

type Response = SuccessResponse | ErrorResponse;

function isSuccess(response: Response): response is SuccessResponse {
  return response.status === "success";
}

const responses: Response[] = [
  { status: "success", data: { id: 1 } },
  { status: "error", message: "Not found" },
  { status: "success", data: { id: 2 } },
];

const successes = responses.filter(isSuccess);
// type: SuccessResponse[]

const data = successes.map(r => r.data);
// type: unknown[]

Without the type predicate, filter would return Response[] and you would lose the narrowing.

Filtering Out Null and Undefined

function isNonNull<T>(value: T): value is NonNullable<T> {
  return value != null;
}

const mixed: (string | null | undefined)[] = ["hello", null, "world", undefined];

const strings = mixed.filter(isNonNull);
// type: string[]

Assertion Functions

Assertion functions narrow types by throwing when the check fails:

function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== "string") {
    throw new TypeError(`Expected string, got ${typeof value}`);
  }
}

function processInput(input: unknown) {
  assertIsString(input);
  // input is string from here onward
  console.log(input.toUpperCase());
}

Unlike type predicates that work in if conditions, assertions narrow the type for all code after the assertion call:

function assertDefined<T>(
  value: T,
  message?: string
): asserts value is NonNullable<T> {
  if (value == null) {
    throw new Error(message ?? "Value is null or undefined");
  }
}

function getUser(id: number): User | null {
  return users.get(id) ?? null;
}

const user = getUser(1);
assertDefined(user, "User not found");
console.log(user.name); // user is User, not User | null

Narrowing with Assignment

TypeScript tracks assignments and narrows accordingly:

let value: string | number;

value = "hello";
console.log(value.toUpperCase()); // value is string

value = 42;
console.log(value.toFixed(2)); // value is number

Narrowing in Callbacks and Closures

Type narrowing does not always persist in callbacks:

function process(value: string | null) {
  if (value !== null) {
    // value is string here

    setTimeout(() => {
      // value is still string | null here!
      // TypeScript cannot guarantee the value has not changed
      console.log(value.toUpperCase()); // Error in strict mode
    }, 1000);
  }
}

The fix is to capture the narrowed value in a const:

function process(value: string | null) {
  if (value !== null) {
    const narrowed = value; // const - cannot be reassigned

    setTimeout(() => {
      console.log(narrowed.toUpperCase()); // OK, narrowed is string
    }, 1000);
  }
}

Real-World Pattern: API Response Handling

Combining discriminated unions with type guards for API responses:

type ApiResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: { code: number; message: string } };

async function fetchApi<T>(url: string): Promise<ApiResult<T>> {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      return {
        ok: false,
        error: { code: response.status, message: response.statusText },
      };
    }
    const data = await response.json();
    return { ok: true, data };
  } catch (err) {
    return {
      ok: false,
      error: { code: 0, message: err instanceof Error ? err.message : "Unknown error" },
    };
  }
}

async function loadUser(id: number) {
  const result = await fetchApi<User>(`/api/users/${id}`);

  if (!result.ok) {
    console.error(`Error ${result.error.code}: ${result.error.message}`);
    return;
  }

  console.log(result.data.name); // result.data is User
}

Wrapping Up

TypeScript narrowing works through control flow analysis. The type system tracks which checks you have performed and narrows types accordingly. Use typeof for primitives, instanceof for class instances, in for property checks, and discriminated unions for tagged object types. Custom type predicates (is) and assertion functions (asserts) let you encapsulate complex checks in reusable functions. The exhaustive check pattern with never catches unhandled union variants at compile time, making your code safer as types evolve.