TypeScript Type Guards

A type guard is any expression that lets the TypeScript compiler narrow a broad or union type down to a more specific one inside a particular branch of your code. When you write if (typeof value === "string"), TypeScript doesn’t just check that condition at runtime — it also uses it to know that, inside the if block, value is definitely a string. Type guards are the bridge between JavaScript’s dynamic runtime checks and TypeScript’s static type system, and mastering them is essential for working with union types safely.

Overview: How Type Guards Work

When you declare a variable or parameter with a union type like string | number, TypeScript only allows you to use members that exist on every member of the union. You can’t call .toUpperCase() on a string | number directly, because a number doesn’t have that method. To use type-specific members, you first have to prove to the compiler which branch of the union you’re in. That proof is a type guard.

TypeScript performs this narrowing through a mechanism called control flow analysis. As the compiler walks through your code — through if/else branches, switch cases, early returns, loops, and logical operators like && — it tracks how the type of each variable changes based on the checks it has already seen. Every time you use a recognized guard, the compiler assigns a narrower type to that variable for the rest of that code path, and reverts it in the other branch.

TypeScript recognizes several built-in guard forms out of the box: typeof, instanceof, the in operator, strict equality checks (===), truthiness checks, and discriminant property checks on discriminated unions. You can also define your own reusable guards with user-defined type predicates (functions whose return type is x is T). Crucially, none of this exists at runtime: it is purely a compile-time analysis. The compiled JavaScript still runs the underlying typeof, instanceof, or property checks — those are ordinary JS — but the type annotations and predicate syntax are erased entirely. Type guards are how TypeScript reasons about types; they don’t change what JavaScript actually executes.

Syntax

The five main forms of type guard are summarized below.

Form Use case Example condition
typeof Narrowing primitives typeof x === "string"
instanceof Narrowing classes err instanceof TypeError
in Narrowing by property presence "fly" in animal
Discriminant check Narrowing discriminated unions shape.kind === "circle"
Custom predicate Reusable, arbitrary logic function isCat(p: Pet): p is Cat

A custom type guard function has this general shape:

function isString(value: unknown): value is string {
  return typeof value === "string";
}
  • Parameter — the value to check, usually typed broadly (unknown or a union).
  • Return type — not boolean, but value is string: a type predicate telling the compiler what to narrow to when the function returns true.
  • Body — any boolean logic; the compiler trusts your predicate, it does not verify the body matches it.

Examples

Example 1: Narrowing with typeof

function printId(id: string | number): void {
  if (typeof id === "string") {
    console.log(`ID (string): ${id.toUpperCase()}`);
  } else {
    console.log(`ID (number): ${id.toFixed(2)}`);
  }
}

printId("abc123");
printId(42);

Output:

ID (string): ABC123
ID (number): 42.00

Inside the if block, id is narrowed to string, so .toUpperCase() is available. In the else block, TypeScript knows the only remaining possibility is number, so .toFixed() is available — even though we never wrote typeof id === "number" explicitly.

Example 2: instanceof for error handling

class ApiError extends Error {
  constructor(public statusCode: number, message: string) {
    super(message);
    this.name = "ApiError";
  }
}

function handleError(error: unknown): void {
  if (error instanceof ApiError) {
    console.log(`API error ${error.statusCode}: ${error.message}`);
  } else if (error instanceof Error) {
    console.log(`Error: ${error.message}`);
  } else {
    console.log("Unknown error", error);
  }
}

handleError(new ApiError(404, "Not Found"));
handleError(new Error("Something broke"));
handleError("just a string");

Output:

API error 404: Not Found
Error: Something broke
Unknown error just a string

This is the idiomatic pattern for catch blocks, where the caught value is typed unknown. instanceof checks the most specific subclass first (ApiError), then falls back to the base Error, then to a catch-all branch for anything that isn’t even an Error.

Example 3: Discriminated unions with a switch

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 area(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 exhaustiveCheck: never = shape;
      throw new Error(`Unhandled shape: ${exhaustiveCheck}`);
    }
  }
}

const shapes: Shape[] = [
  { kind: "circle", radius: 2 },
  { kind: "rectangle", width: 3, height: 4 },
  { kind: "triangle", base: 5, height: 6 },
];

for (const shape of shapes) {
  console.log(`${shape.kind}: ${area(shape).toFixed(2)}`);
}

Output:

circle: 12.57
rectangle: 12.00
triangle: 15.00

Each interface shares a common literal-typed kind property — the discriminant. Checking shape.kind in a switch narrows shape to the matching interface in each case, so shape.radius is only visible in the "circle" case, and so on. The default branch assigns shape to a variable typed never; if you later add a new shape to the union and forget a case, that assignment fails to compile, catching the missing case at build time. This pattern is called an exhaustiveness check.

Under the Hood: Narrowing and Erasure

When the compiler sees if (typeof id === "string") { ... } else { ... }, it doesn’t run your code — it performs static analysis. For a union member type, it asks: “which constituents of this union are compatible with the condition being true?” Everything compatible flows into the if branch’s type; everything else (the complement) flows into the else branch. The same logic applies to instanceof (checking the prototype chain statically against known classes), in (checking which union members declare that property), and discriminant comparisons (checking which members have that literal value for the shared property).

User-defined predicates work the same way, except you supply the narrowing logic instead of the compiler inferring it from a recognized pattern. The compiler doesn’t re-derive or verify your predicate’s body against the return type — it simply trusts that if your function returns true, the value really is a T. That trust is powerful (it lets you narrow using any logic, including combining multiple checks) but it also means a poorly written predicate can lie to the type system without triggering an error.

Finally, remember that all of this narrowing exists purely in the type layer. Once compiled to JavaScript, value is string and every other type annotation disappears completely — the emitted JS is just the plain typeof/instanceof/property checks you already know from JavaScript. Type guards make the type checker smarter; they add nothing to the runtime behavior of your program.

Common Mistakes

Mistake 1: Using typeof to distinguish two object types

typeof only distinguishes JavaScript’s primitive categories ("string", “number”, “boolean”, “object”, “function”, etc.). Both arrays and plain objects report "object", so typeof cannot tell them apart:

function printLength(value: string[] | Record<string, number>): void {
  if (typeof value === "object") {
    console.log(value.length);
  }
}

This fails to compile with an error like “Property ‘length’ does not exist on type ‘string[] | Record<string, number>’.” Because both union members are objects, the typeof check narrows nothing — value keeps its full union type, and Record<string, number> has no .length. Use Array.isArray instead, which TypeScript recognizes as a dedicated type guard:

function printLength(value: string[] | Record<string, number>): void {
  if (Array.isArray(value)) {
    console.log(value.length);
  } else {
    console.log(Object.keys(value).length);
  }
}

printLength(["a", "b", "c"]);
printLength({ a: 1, b: 2 });

Output:

3
2

Mistake 2: Forgetting the is predicate on a custom guard

If a helper function just returns boolean instead of a type predicate, calling it doesn’t narrow anything at the call site, even though the check itself is correct:

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

function shout(value: unknown): void {
  if (isString(value)) {
    console.log(value.toUpperCase());
  }
}

This fails with “Property ‘toUpperCase’ does not exist on type ‘unknown’.” Because isString returns a plain boolean, the compiler has no way to know that a true result means value is a string, so value remains unknown inside the if. Adding the value is string predicate fixes it:

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

function shout(value: unknown): void {
  if (isString(value)) {
    console.log(value.toUpperCase());
  }
}

shout("hello");
shout(42);

Output:

HELLO

Best Practices

  • Prefer discriminated unions with a shared literal kind/type property over loosely related interfaces — they enable the most reliable narrowing and exhaustiveness checks.
  • Add a default case with a never-typed variable in switch statements over discriminated unions so the compiler flags any unhandled case as soon as you extend the union.
  • Use unknown instead of any for values of uncertain type (like catch parameters or parsed JSON), then narrow with type guards before using them.
  • Reach for built-in guards (typeof, instanceof, in, Array.isArray) before writing a custom predicate — they’re simpler and the compiler verifies them for you.
  • When you do write a custom type predicate, keep its body genuinely equivalent to the claimed type — the compiler won’t catch a mismatch, but incorrect narrowing will cause confusing bugs later.
  • Combine guards with && and early returns to keep narrowing simple; deeply nested conditionals make it harder to track what’s narrowed where.

Practice Exercises

  • Exercise 1: Write a function describe(value: string | number | boolean) that uses a typeof guard to log a different message for each type (e.g. "It's text: ...", "It's a number: ...", "It's a flag: ...").
  • Exercise 2: Define a discriminated union type Result = { status: "success"; data: string } | { status: "error"; message: string }, then write a function report(result: Result) that narrows on status and logs the appropriate field.
  • Exercise 3: Write a custom type guard isNumberArray(value: unknown): value is number[] that checks the value is an array and every element is a number, then use it inside a function that sums the array.

Summary

  • Type guards let TypeScript narrow a broader or union type to a more specific one within a code branch.
  • Built-in guard forms include typeof, instanceof, the in operator, and discriminant property checks.
  • Custom guards use a type predicate return type, value is T, which the compiler trusts without re-verifying.
  • Discriminated unions plus a switch with a never default case give you compile-time exhaustiveness checking.
  • All narrowing is compile-time only; type annotations and predicates are fully erased from the emitted JavaScript.