Skip to content
JL

Home

About

Blog

Contact

Shop

Portfolio

Privacy

TOS

Click to navigate

  1. Home
  2. Joshua R. Lehman's Blog
  3. Type Guards: Runtime Type Checking in TypeScript

Table of contents

  • Share on X
  • Discuss on X

Related Articles

Union Types: Multiple Possibilities in TypeScript
TypeScript
7m
Apr 5, 2026

Union Types: Multiple Possibilities in TypeScript

A practical guide to TypeScript union types — combining multiple types with OR logic, accessing shared properties, and narrowing unions safely at runtime.

#Union Types#Type Narrowing+5
Literal Types and Const Assertions in TypeScript
TypeScript
7m
Apr 19, 2026

Literal Types and Const Assertions in TypeScript

A practical guide to TypeScript literal types and as const — narrowing values to exact possibilities, building type-safe enumerations, and preserving tuple and object shapes.

#Literal Types#Const Assertions+5
Intersection Types: Combining Types in TypeScript
TypeScript
8m
Apr 12, 2026

Intersection Types: Combining Types in TypeScript

A practical guide to TypeScript intersection types — combining multiple types into one with AND logic, merging object shapes, and building flexible mixin patterns.

#Intersection Types#TypeScript Types+5
Ask me anything! 💬

© Joshua R. Lehman

Full Stack Developer

Crafted with passion • Built with modern web technologies

2026 • All rights reserved

Contents

  • What Are Type Guards
  • The typeof Operator
  • The instanceof Operator
  • Custom Type Guard Functions
  • User-Defined Type Guards with Type Predicates
  • Narrowing with Discriminated Unions
  • Type Guard Best Practices
  • Key Takeaways
TypeScript

Type Guards: Runtime Type Checking in TypeScript

April 26, 2026•8 min read
Joshua R. Lehman
Joshua R. Lehman
Author
TypeScript type guards and runtime type checking
Type Guards: Runtime Type Checking in TypeScript

What Are Type Guards

Think of a type guard like a security checkpoint at an airport. The checkpoint doesn't change who you are — it just confirms your identity so you can be treated accordingly on the other side. TypeScript's type guards work the same way: they perform a runtime check on a value, and if the check passes, TypeScript narrows the type so you can work with it safely.

In the last post, you learned about literal types and as const — compile-time tools for constraining values to exact possibilities. But TypeScript's knowledge of types ends at compilation. When your application runs, a function might receive a string | number, an API response might be typed as unknown, or a class hierarchy might include several subclasses. Type guards let you inspect values at runtime and tell TypeScript — with full type safety — what you're working with.

Type Narrowing vs Type Guards

Type narrowing is the broader concept: TypeScript automatically narrows types inside conditionals, after null checks, and after assignments. Type guards are the specific checks that trigger narrowing — either built-in operators like typeof and instanceof, or custom functions you write with a special return type called a type predicate.

The typeof Operator

The most common type guard you'll write is a simple typeof check. TypeScript understands the result of typeof and narrows the type accordingly inside the if block:

function formatValue(value: string | number): string {
  if (typeof value === "string") {
    // TypeScript knows: value is string here
    return value.toUpperCase();
  }
  // TypeScript knows: value is number here
  return value.toFixed(2);
}
 
formatValue("hello"); // "HELLO"
formatValue(3.14159); // "3.14"

TypeScript recognises these typeof results and narrows accordingly: "string", "number", "boolean", "bigint", "symbol", "undefined", "object", and "function".

The narrowing is precise — once TypeScript has ruled out a type inside one branch, it removes it from the type in subsequent branches:

function describe(value: string | number | boolean): string {
  if (typeof value === "string") {
    return `String of length ${value.length}`;
  }
  if (typeof value === "number") {
    return `Number: ${value.toFixed(0)}`;
  }
  // TypeScript knows: only boolean remains
  return `Boolean: ${value ? "yes" : "no"}`;
}

typeof null Is object

A long-standing JavaScript quirk: typeof null === "object". This means typeof alone cannot distinguish null from a real object. Always add an explicit !== null check when working with nullable object types: if (value !== null && typeof value === "object").

The instanceof Operator

For class instances, instanceof is the right tool. TypeScript narrows a value to the specific class type when an instanceof check passes:

class Dog {
  bark(): void {
    console.log("Woof!");
  }
}
 
class Cat {
  purr(): void {
    console.log("Purr...");
  }
}
 
function makeSound(animal: Dog | Cat): void {
  if (animal instanceof Dog) {
    // TypeScript knows: animal is Dog here
    animal.bark();
  } else {
    // TypeScript knows: animal is Cat here
    animal.purr();
  }
}

instanceof works with any class in your codebase, and it also works with built-in classes like Date, Error, Map, and Array:

function processError(err: unknown): string {
  if (err instanceof Error) {
    // TypeScript knows: err is Error here
    return `Error: ${err.message}`;
  }
  if (err instanceof TypeError) {
    // TypeError extends Error — this works too
    return `Type Error: ${err.message}`;
  }
  return "Unknown error occurred";
}

This is especially useful in catch blocks, where the caught value has type unknown in strict TypeScript. Using instanceof Error is the idiomatic way to safely access .message and .stack.

Custom Type Guard Functions

typeof and instanceof cover primitives and class instances, but they can't help you with plain objects, interfaces, or complex shapes. That's where custom type guard functions come in. A custom type guard is a regular function that returns a boolean — but you call it to narrow a type:

interface Bird {
  species: string;
  canFly: boolean;
  wingSpan: number;
}
 
interface Fish {
  species: string;
  canFly: false;
  waterType: "fresh" | "salt";
}
 
function isBird(animal: Bird | Fish): boolean {
  return "wingSpan" in animal;
}
 
function describeAnimal(animal: Bird | Fish): void {
  if (isBird(animal)) {
    // TypeScript does NOT narrow here — it still sees Bird | Fish
    console.log(animal.wingSpan); // Error: property 'wingSpan' does not exist on type 'Fish'
  }
}

The problem: a function returning boolean doesn't carry any type information. TypeScript can't know that true means "it's a Bird". To fix this, you need a type predicate.

User-Defined Type Guards with Type Predicates

A type predicate is a special return type annotation in the form parameter is Type. It tells TypeScript: "if this function returns true, then the specified parameter is the given type."

Here's the fixed version of the example above:

function isBird(animal: Bird | Fish): animal is Bird {
  return "wingSpan" in animal;
}
 
function describeAnimal(animal: Bird | Fish): void {
  if (isBird(animal)) {
    // TypeScript knows: animal is Bird here
    console.log(`${animal.species} with a ${animal.wingSpan}cm wingspan`);
    console.log(`Can fly: ${animal.canFly}`);
  } else {
    // TypeScript knows: animal is Fish here
    console.log(`${animal.species} lives in ${animal.waterType} water`);
  }
}

The return type animal is Bird is the type predicate. The parameter name before is must match one of the function's parameter names exactly.

Type predicates are particularly valuable when validating data from external sources like API responses:

interface ApiUser {
  id: string;
  email: string;
  role: "admin" | "user" | "guest";
}
 
function isApiUser(data: unknown): data is ApiUser {
  return (
    typeof data === "object" &&
    data !== null &&
    "id" in data &&
    "email" in data &&
    "role" in data &&
    typeof (data as ApiUser).id === "string" &&
    typeof (data as ApiUser).email === "string"
  );
}
 
async function fetchUser(id: string): Promise<ApiUser> {
  const response = await fetch(`/api/users/${id}`);
  const data: unknown = await response.json();
 
  if (!isApiUser(data)) {
    throw new Error("Invalid user data from API");
  }
 
  // TypeScript knows: data is ApiUser here
  return data;
}

Consider a Validation Library for Complex Guards

For production applications with many API shapes, hand-writing type guards becomes tedious. Libraries like Zod, Valibot, and io-ts let you define schemas that both validate at runtime and infer TypeScript types automatically. They generate type guard functions for you under the hood.

Narrowing with Discriminated Unions

Type guards work especially well with discriminated unions — union types where each member has a shared property with a unique literal type. TypeScript can use a simple equality check on the discriminant to narrow the full type:

type LoadingState = {
  status: "loading";
};
 
type SuccessState = {
  status: "success";
  data: string[];
};
 
type ErrorState = {
  status: "error";
  message: string;
};
 
type AppState = LoadingState | SuccessState | ErrorState;
 
function renderState(state: AppState): string {
  switch (state.status) {
    case "loading":
      // TypeScript knows: state is LoadingState
      return "Loading...";
    case "success":
      // TypeScript knows: state is SuccessState
      return `Loaded ${state.data.length} items`;
    case "error":
      // TypeScript knows: state is ErrorState
      return `Error: ${state.message}`;
    default:
      // TypeScript knows this is unreachable if all cases are covered
      const exhaustiveCheck: never = state;
      return exhaustiveCheck;
  }
}

The status property is the discriminant — a literal type that uniquely identifies each union member. No instanceof or custom predicates needed. TypeScript narrows entirely on equality checks against the discriminant.

Exhaustiveness Checking with never

The default: const x: never = state pattern is a compile-time safety net. If you ever add a new member to AppState without updating renderState, TypeScript will report an error: it can no longer assign the new type to never. This catches missing cases before they become runtime bugs.

Type Guard Best Practices

Choose the right tool for the job:

  • typeof — for primitive types (string, number, boolean, undefined)
  • instanceof — for class instances and built-in classes
  • in operator — for checking if a property exists on an object
  • Custom type predicates — for complex shapes, interfaces, and API data
  • Discriminated unions — when you control the type definitions

Write honest type guards. A type guard function that lies — returning true when the type check is actually wrong — defeats the entire purpose and creates hard-to-debug runtime errors. Make sure your predicate logic genuinely verifies the shape you claim:

// Bad — incomplete check, lies about the type
function isUser(value: unknown): value is ApiUser {
  return typeof value === "object" && value !== null;
}
 
// Good — checks all required properties and their types
function isUser(value: unknown): value is ApiUser {
  return (
    typeof value === "object" &&
    value !== null &&
    typeof (value as ApiUser).id === "string" &&
    typeof (value as ApiUser).email === "string" &&
    ["admin", "user", "guest"].includes((value as ApiUser).role)
  );
}

Never Lie to TypeScript

A dishonest type predicate — one that claims value is SomeType without fully validating the shape — moves runtime errors from TypeScript-detectable locations to somewhere deep in your application logic. If you're not sure how to validate a field, it's better to throw an error than to return true incorrectly.

Combine type guards with early returns to keep your logic flat and readable:

function processPayment(amount: string | number): number {
  if (typeof amount === "string") {
    const parsed = parseFloat(amount);
    if (isNaN(parsed)) throw new Error(`Invalid amount: ${amount}`);
    return parsed;
  }
  return amount; // TypeScript knows it's number here
}

Key Takeaways

  • Type guards narrow a broad type to a specific one inside a conditional block — TypeScript tracks the narrowing automatically
  • typeof narrows primitive types; remember typeof null === "object" and add explicit null checks
  • instanceof narrows class instances and works with built-in classes like Error, Date, and Map
  • Custom type predicates use the value is Type return annotation to tell TypeScript what a true result means
  • Discriminated unions with a status or kind field allow narrowing with simple equality checks and enable exhaustiveness checking via never
  • Write honest type guards — an incorrect predicate creates runtime bugs that TypeScript can't catch

You Can Now Bridge Compile Time and Runtime

Type guards complete the picture for working with union types: you know how to define them with |, combine them with &, constrain them to literals, and now narrow them precisely at runtime. Next up: discriminated unions and tagged types — a pattern that formalises everything you just learned into a systematic approach for modelling application state.