TypeScript Type Narrowing: Discriminated Unions, Predicates, and Asserts Done Right

You’ve been there. You write perfectly typed TypeScript, everything looks great at compile time, and then at runtime some function blows up with Cannot read properties of undefined. You check the type, it says string | null | User | ApiError, and now you’re writing a wall of if (typeof x === ...) checks that the compiler still doesn’t fully trust.

TypeScript’s type system is genuinely powerful — but it only pays out when you know how to guide it. Type narrowing is the mechanism that takes a broad, ambiguous union type and collapses it down to exactly what you need at each branch of your code. Do it right, and the compiler becomes your runtime safety net. Do it wrong, and you’re casting to any and hoping for the best.

This article covers the three tools you need to own this: discriminated unions, type predicates, and assertion functions. We’ll go deep, cover the gotchas, and build toward patterns you can actually use in production code.


Why naive narrowing breaks down

Start with the obvious approach. You have a function that returns different shapes depending on what happened:

type Result = 
  | { data: User; error: null }
  | { data: null; error: string };

function handleResult(result: Result) {
  if (result.data) {
    // TypeScript knows data is User here — good
    console.log(result.data.name);
  }
}

That works until your data can itself be falsy. What if data is 0, "", or an empty array? if (result.data) silently excludes those valid values. You’ve introduced a runtime bug that TypeScript happily accepted because you used a truthy check instead of a structural one.

This is the core problem: TypeScript can only narrow as well as the check you provide. Garbage in, garbage out.


Discriminated unions: let the shape tell you what it is

A discriminated union (also called a tagged union) solves the falsy-check problem by adding an explicit literal field — the discriminant — whose sole purpose is to identify which member of the union you’re dealing with.

type LoadingState = { status: "loading" };
type SuccessState = { status: "success"; data: string[] };
type ErrorState   = { status: "error"; message: string; code: number };

type AsyncState = LoadingState | SuccessState | ErrorState;

The status field is the discriminant. Its value is a string literal, never the same across members. Now narrowing is clean:

function render(state: AsyncState) {
  switch (state.status) {
    case "loading":
      return "<spinner />";
    case "success":
      return state.data.join(", ");  // TypeScript knows data exists here
    case "error":
      return `Error ${state.code}: ${state.message}`;
  }
}

No falsy checks. No optional chaining. The compiler tracks exactly which branch you’re in, and the types narrow automatically.

The exhaustiveness trick. Add a default branch that assigns state to never:

function render(state: AsyncState) {
  switch (state.status) {
    case "loading": return "<spinner />";
    case "success": return state.data.join(", ");
    case "error":   return `Error ${state.code}: ${state.message}`;
    default: {
      const _exhaustive: never = state;
      throw new Error(`Unhandled state: ${JSON.stringify(_exhaustive)}`);
    }
  }
}

If you later add type TimeoutState = { status: "timeout"; retryAfter: number } to the union and forget to handle it, the compiler will error on the never assignment. The switch becomes a compile-time contract that forces you to handle new cases. This is one of the genuinely useful patterns in TypeScript — it turns a runtime surprise into a build-time failure.

Gotcha: the discriminant must be a literal type

This does not work as a discriminated union:

// Wrong — status is just `string`, not a literal
type BadState = { status: string; data?: unknown };

TypeScript can’t narrow on string. The discriminant field must be typed as a string literal ("loading"), numeric literal (1 | 2 | 3), boolean literal (true | false), or a null/undefined. If you’re receiving types from an external library or API and they used string, you’ll need to cast or validate at the boundary.

Gotcha: shared properties confuse the narrowing

If two union members have the same field name but different types (not as the discriminant), you can get unexpected behavior:

type A = { kind: "a"; value: string };
type B = { kind: "b"; value: number };
type AB = A | B;

function use(x: AB) {
  if (x.kind === "a") {
    // x.value is string here — correct
  }
}

This works fine. But if you try to read value before narrowing on kind, you get string | number. This trips people up when they restructure code and move the narrowing check around. Always narrow before accessing variant-specific fields.


Type predicates: custom narrowing functions

Sometimes your narrowing logic is too complex to express with a simple === check. Maybe you’re validating a parsed JSON object, checking whether an API response has the shape you expect, or filtering an array by type. For these cases, you write a user-defined type guard using the is keyword.

type Cat = { kind: "cat"; meow: () => void };
type Dog = { kind: "dog"; bark: () => void };
type Animal = Cat | Dog;

function isCat(animal: Animal): animal is Cat {
  return animal.kind === "cat";
}

The return type animal is Cat is the predicate. When this function returns true, TypeScript narrows the type of animal to Cat in the truthy branch. When it returns false, it narrows to the complement — in this case Dog.

Where this gets genuinely useful is with arrays:

const animals: Animal[] = [...];
const cats: Cat[] = animals.filter(isCat);

Without the predicate, animals.filter(isCat) would give you Animal[]. With it, TypeScript knows the result is Cat[]. This pattern is everywhere in real codebases — filtering a mixed array down to one type.

Predicates for unknown data

The bigger use case is validating data that comes in as unknown — from JSON.parse, fetch responses, localStorage, message bus events:

interface User {
  id: number;
  name: string;
  email: string;
}

function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    "name" in value &&
    "email" in value &&
    typeof (value as any).id === "number" &&
    typeof (value as any).name === "string" &&
    typeof (value as any).email === "string"
  );
}

const raw: unknown = JSON.parse(response);
if (isUser(raw)) {
  // raw is User here — safe to use
  console.log(raw.name);
}

Yes, there’s a (value as any) cast in the validation itself. That’s unavoidable — you’re crossing the unknown boundary on purpose. The predicate’s contract is that if it returns true, the value genuinely conforms to the type. The cast is contained and intentional.

For production code where you’re validating a lot of shapes, consider libraries like zod or valibot. They generate both runtime validators and TypeScript types from a single schema definition — the predicate pattern under the hood, with much less boilerplate.

Gotcha: TypeScript cannot verify your predicate is correct

This is the most important thing to understand about type predicates: TypeScript trusts you completely. You can write a predicate that lies:

function isString(x: unknown): x is string {
  return true; // always returns true — TypeScript won't catch this
}

const n: unknown = 42;
if (isString(n)) {
  console.log(n.toUpperCase()); // runtime crash — n is number, not string
}

The compiler sees isString returns boolean and annotates the branch as string. It doesn’t inspect the function body to verify the claim. If your predicate has a bug, you get a runtime error with a confusingly typed stack trace.

Write tests for your predicates. They’re your only defense here.

Gotcha: narrowing doesn’t survive callbacks

Type narrowing is often lost when you pass a narrowed value into a callback or closure that TypeScript can’t reason about statically:

function useUser(user: User | null) {
  if (user === null) return;
  // user is User here

  setTimeout(() => {
    console.log(user.name); // TypeScript may still say User | null in some contexts
  }, 100);
}

In modern TypeScript (4.9+) this specific example usually works, but the general principle holds: narrowing is flow-based and local. If you’re passing a value into something that executes asynchronously or in a different scope, the narrowing can be lost. When this happens, capture the narrowed value in a const with explicit type, or use an assertion (see below).


Assertion functions: narrowing that throws

Type predicates return a boolean. Sometimes you don’t want to return — you want to crash if the value doesn’t match. Enter assertion functions, introduced in TypeScript 3.7, using the asserts keyword.

function assertUser(value: unknown): asserts value is User {
  if (!isUser(value)) {
    throw new Error(`Expected User, got: ${JSON.stringify(value)}`);
  }
}

const raw: unknown = JSON.parse(data);
assertUser(raw); // throws if raw isn't a User
// raw is User from this point forward in the current scope
console.log(raw.name); // safe

Unlike a predicate, an assertion function doesn’t return anything meaningful — it either completes normally (and TypeScript narrows the type) or throws (and execution stops). The asserts annotation tells the compiler: "if this function returns, the type holds."

There’s also the simpler form for asserting that something is not null or undefined:

function assertDefined<T>(value: T | null | undefined): asserts value is T {
  if (value == null) {
    throw new Error("Expected defined value, got null or undefined");
  }
}

const user = getUser(); // returns User | null
assertDefined(user);
// user is User here

When to use asserts vs predicates

Use a predicate when you have a legitimate code path that handles both cases — your function keeps running either way and you branch on the result.

Use an assertion when a failure is a programming error or an invariant violation — something that should never happen in correct code, and if it does, you want a loud crash with a clear error message rather than silent misbehavior.

In practice: predicates at system boundaries where you’re validating untrusted data and want to report gracefully. Assertions inside your own codebase where a bad value would mean your code has a bug.

Gotcha: assertions don’t work with arrow functions (pre-4.9)

In older TypeScript versions, the asserts return type annotation only works on function declarations and expressions, not arrow functions. This was fixed in 4.9, but if you’re on an older codebase:

// Might not work in TS < 4.9
const assertDefined = <T>(x: T | null): asserts x is T => { ... };

// Use this instead
function assertDefined<T>(x: T | null): asserts x is T { ... }

Check your tsconfig.json target and TypeScript version before writing assertion arrow functions and wondering why the compiler is confused.

Combining assertions with discriminated unions

The real power comes from combining all three tools. You get external data, validate it with a predicate, then use a discriminated union to handle different success/failure shapes cleanly:

type ApiResponse<T> =
  | { status: "ok"; data: T }
  | { status: "error"; message: string; statusCode: number };

function isApiResponse<T>(
  value: unknown,
  isT: (x: unknown) => x is T
): value is ApiResponse<T> {
  if (typeof value !== "object" || value === null) return false;
  const v = value as Record<string, unknown>;
  if (v.status === "ok") return isT(v.data);
  if (v.status === "error") {
    return typeof v.message === "string" && typeof v.statusCode === "number";
  }
  return false;
}

function assertApiResponse<T>(
  value: unknown,
  isT: (x: unknown) => x is T
): asserts value is ApiResponse<T> {
  if (!isApiResponse(value, isT)) {
    throw new Error(`Invalid API response shape: ${JSON.stringify(value)}`);
  }
}

// Usage
const raw: unknown = await fetchSomething();
assertApiResponse(raw, isUser);

switch (raw.status) {
  case "ok":
    processUser(raw.data); // data is User
    break;
  case "error":
    logError(raw.statusCode, raw.message);
    break;
}

This pattern handles the full lifecycle: validate at the network boundary with an assertion, then use a discriminated union to exhaustively handle every shape inside your application logic. The types are accurate throughout, and if the API changes its shape, you fail fast at the boundary instead of silently corrupting your state.


Production-ready patterns to take away

One validation layer, used everywhere. Don’t scatter if (typeof x === ...) checks across your codebase. Write one predicate per external type, put them in a validators.ts file, and import them at every entry point (API handlers, WebSocket messages, localStorage reads). Your internal code can then trust its types completely.

Exhaust your unions at build time. Any time you switch on a discriminant, add the never exhaustiveness check. It costs one line and saves you from silent breakage when the union grows.

Prefer asserts at application boundaries, predicates inside logic. If you’re in an API route handler and you get unexpected data, throw loudly. If you’re filtering an array or branching in a component, return boolean and handle both paths.

Don’t forget to test your predicates. They’re not verified by the type system. A predicate with a bug is worse than no predicate — it gives you false confidence. Unit test them with valid and invalid inputs.

Use a schema library for complex shapes. If your validators are getting long and nested, that’s a sign to bring in Zod or Valibot. They give you .parse() (which throws — assertion-style) and .safeParse() (which returns a discriminated union result). Both patterns covered, zero boilerplate.


The combination of discriminated unions, predicates, and asserts is how you write TypeScript that’s actually safe — not just "it compiled" safe, but "the types reflect reality at runtime" safe. It takes discipline to set up at boundaries, but once it’s in place, you stop chasing undefined is not a function and start trusting your types to mean something.

👁 Views: 202,912 · Unique visitors: 57,425