DevLift
Back to Blog

TypeScript Type Narrowing and Discriminated Unions

Stop casting with `as` and `!`. Discriminated unions give TypeScript enough information to narrow types automatically — making impossible states unrepresentable and exhaustiveness checking free.

Admin
August 2, 20265 min read2 views

TypeScript Type Narrowing and Discriminated Unions

You're writing a function that handles an API response. It can succeed, fail, or still be loading. So you slap together a quick union type and call it a day:

type AsyncState = {
  data?: User;
  error?: Error;
  loading: boolean;
};

Now everywhere you use this, TypeScript has no idea what's actually going on. Is data present because loading finished, or is error the one you should be checking? You start sprinkling ! assertions and as User casts all over the place just to make the compiler shut up. You've traded type safety for noise.

The fix isn't more discipline. It's a different data shape — one that TypeScript can actually reason about.

The Problem with Open Unions

Here's what that AsyncState usage looks like in practice:

// Bad: TypeScript can't help you here
function renderUser(state: AsyncState) {
  if (state.loading) {
    return <Spinner />;
  }
 
  // TypeScript sees data as User | undefined — you have to assert
  if (state.error) {
    return <ErrorMessage message={state.error.message} />;
  }
 
  // Is data definitely here? TypeScript doesn't know.
  return <UserCard user={state.data!} />; // <-- smell
}

The type allows states that can't exist simultaneously — loading: true with data set, error and data both present, etc. You're carrying the cognitive overhead of what states are valid, and TypeScript can't catch you when you get it wrong.

The same problem shows up in lots of places:

  • Notification shapes (email, sms, push — each with different fields)
  • Payment provider responses (Stripe vs Razorpay vs manual)
  • Form step variants in a multi-step wizard
  • WebSocket message types

Type Narrowing: What TypeScript Already Does for You

Before reaching for discriminated unions, it's worth understanding how TypeScript already narrows types in normal control flow.

typeof for primitives:

function format(value: string | number | boolean) {
  if (typeof value === "string") {
    return value.toUpperCase(); // string here
  }
  if (typeof value === "number") {
    return value.toFixed(2); // number here
  }
  return String(value); // boolean here
}

instanceof for classes:

function handleError(error: Error | AxiosError | ValidationError) {
  if (error instanceof AxiosError) {
    return `HTTP ${error.response?.status}: ${error.message}`;
  }
  if (error instanceof ValidationError) {
    return error.fields.map((f) => f.message).join(", ");
  }
  return error.message;
}

in operator for object shapes:

type Cat = { meow(): void };
type Dog = { bark(): void };
 
function makeSound(animal: Cat | Dog) {
  if ("meow" in animal) {
    animal.meow(); // Cat
  } else {
    animal.bark(); // Dog
  }
}

These work fine for simple cases. But when you have 4+ variants with overlapping fields, they break down fast. That's where discriminated unions shine.

The Pattern: Discriminated Unions

A discriminated union is a union type where every member has a shared literal property — the discriminant — that TypeScript can use to narrow to the exact variant.

Here's the AsyncState rewritten properly:

type AsyncState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

Every variant has status, and each has a unique literal value. Now TypeScript knows exactly what's in scope after you check status:

function renderUser(state: AsyncState<User>) {
  switch (state.status) {
    case "idle":
      return <EmptyState />;
    case "loading":
      return <Spinner />;
    case "success":
      // state.data is User — no assertion needed
      return <UserCard user={state.data} />;
    case "error":
      // state.error is Error — TypeScript knows
      return <ErrorMessage message={state.error.message} />;
  }
}

No !. No as. The type system handles it.

Rendering diagram...

Exhaustiveness Checking: Let the Compiler Enforce Your Cases

Here's the real superpower: if you add a new variant later and forget to handle it, TypeScript will tell you. You just need one helper:

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

Add a default branch that calls it:

function renderUser(state: AsyncState<User>) {
  switch (state.status) {
    case "idle":
      return <EmptyState />;
    case "loading":
      return <Spinner />;
    case "success":
      return <UserCard user={state.data} />;
    case "error":
      return <ErrorMessage message={state.error.message} />;
    default:
      return assertNever(state); // compile error if a case is missing
  }
}

Now add a new variant:

type AsyncState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error }
  | { status: "stale"; data: T; staleSince: Date }; // new

The default: assertNever(state) line immediately errors because state can still be { status: "stale"; ... } — it's not never anymore. You can't ship broken code without TypeScript screaming first.

Put assertNever in a shared utils file. It's one of those tiny functions that earns its keep every time you touch a discriminated union.

Real-World Example: Notification System

Here's a practical example — a notification service where each channel has different fields:

type Notification =
  | {
      channel: "email";
      to: string;
      subject: string;
      html: string;
    }
  | {
      channel: "sms";
      to: string; // phone number
      body: string;
    }
  | {
      channel: "push";
      deviceToken: string;
      title: string;
      body: string;
      badge?: number;
    };
 
async function send(notification: Notification): Promise<void> {
  switch (notification.channel) {
    case "email":
      await emailClient.send({
        to: notification.to,
        subject: notification.subject,
        html: notification.html,
      });
      break;
    case "sms":
      await twilioClient.messages.create({
        to: notification.to,
        body: notification.body,
      });
      break;
    case "push":
      await fcm.send({
        token: notification.deviceToken,
        notification: {
          title: notification.title,
          body: notification.body,
        },
        apns: { payload: { aps: { badge: notification.badge } } },
      });
      break;
    default:
      assertNever(notification);
  }
}

Without discriminated unions, send would need if (notification.channel === "email" && "subject" in notification) — plus you'd need non-null assertions everywhere because TypeScript wouldn't know which fields coexist.

Custom Type Predicates

Sometimes you can't use a switch statement — maybe you're filtering an array or checking inside a callback. That's where type predicates come in:

type SuccessState<T> = { status: "success"; data: T };
type ErrorState = { status: "error"; error: Error };
type LoadingState = { status: "loading" };
 
type AsyncState<T> = SuccessState<T> | ErrorState | LoadingState;
 
// Type predicate — the "is" syntax tells TypeScript what this returns
function isSuccess<T>(state: AsyncState<T>): state is SuccessState<T> {
  return state.status === "success";
}
 
// Now you can use it in filter/find:
const successfulRequests = states.filter(isSuccess);
// successfulRequests is SuccessState<T>[] — not AsyncState<T>[]

If you learned this pattern before TypeScript 5.5, you learned it with a caveat that no longer applies. The old rule was that an inline states.filter(s => s.status === "success") still gave you AsyncState<T>[], because the compiler wouldn't infer a type guard from an arbitrary boolean expression. TypeScript 5.5 added inferred type predicates, and it does now — checked on 5.9.3, the inline version narrows to SuccessState<T>[] on its own:

const inline = states.filter((s) => s.status === "success");
// SuccessState<T>[] on TypeScript 5.5+ — no explicit `is` needed
inline[0].data; // compiles

Inference has limits, though. It only kicks in for a function whose body is a single return of a narrowing expression over a parameter it never reassigns. Pass a reference instead of an arrow and you get nothing:

const nulls: Array<User | null> = [];
const stillNullable = nulls.filter(Boolean);
// (User | null)[] — `Boolean` is just a function value, there is no body to analyse

So the explicit is predicate still earns its place: for anything more involved than one comparison, and as a documented contract at a module boundary where you don't want the return type to shift when someone edits the body.

⚠️

Type predicates are a lie you tell TypeScript. If your implementation is wrong, the compiler believes you anyway. Only use them when narrowing is genuinely impossible inline.

Variation: Using kind Instead of type

The discriminant field name doesn't matter. type, kind, tag, status, variant — pick one and be consistent across your codebase. The only rule is it must be a literal type, not a general string.

// Works just as well as "type"
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rect"; 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;
    case "rect":
      return shape.width * shape.height;
    case "triangle":
      return (shape.base * shape.height) / 2;
    default:
      return assertNever(shape);
  }
}
💡

If the discriminant is going over the wire to a typed backend, prefer kind or variant over type. type is a builtin in Python and a keyword in Go, so a generated dataclass or struct field named type needs an alias on the other side. Nothing breaks — it's just friction you can avoid for free by picking a different word.

Variation: Nested Discriminated Unions

You can nest them. A common pattern for API responses where different resources have different shapes:

type ApiResponse<T> =
  | { ok: true; data: T }
  | { ok: false; error: { code: string; message: string } };
 
type UserResponse = ApiResponse<User>;
type PostListResponse = ApiResponse<Post[]>;
 
async function fetchUser(id: string): Promise<UserResponse> {
  try {
    const user = await db.user.findUnique({ where: { id } });
    if (!user) {
      return { ok: false, error: { code: "NOT_FOUND", message: "User not found" } };
    }
    return { ok: true, data: user };
  } catch {
    return { ok: false, error: { code: "DB_ERROR", message: "Database error" } };
  }
}
 
// At the call site:
const result = await fetchUser(id);
if (result.ok) {
  console.log(result.data.email); // User — no assertion
} else {
  console.error(result.error.code); // { code, message }
}

This is the same idea that libraries like neverthrow and Rust's Result<T, E> type are built on. You can roll your own in 5 lines.

When NOT to Use Discriminated Unions

They're not free. A few scenarios where they add more noise than signal:

Simple boolean flags. If your type is just "maybe a User":

// Overkill
type MaybeUserVerbose =
  | { found: true; user: User }
  | { found: false };
 
// Just use this
type MaybeUser = User | null;

Two variants with identical fields. If the only difference is semantic, not structural, a union adds ceremony without narrowing benefit.

When you control neither the input nor the output. If you're wrapping a third-party API that returns wildly inconsistent shapes, you'll spend more time fighting the type system than using it. A validation layer at the boundary (Zod, Valibot) is usually better here.

Performance-critical hot paths with many variants. Switch statements on string discriminants are fast, but if you have 50+ variants and this runs millions of times per second, profile it. In practice this almost never matters for typical web apps.

The Takeaway

Discriminated unions are the single highest-use TypeScript pattern for modeling state. Once you internalize the shape — shared literal discriminant, variant-specific fields, exhaustive switch — you start seeing opportunities everywhere: async state, form steps, WebSocket messages, payment flows, notification channels.

The combination of discriminated unions + assertNever is a refactoring harness. Add a new variant, and the compiler shows you everywhere you haven't handled it yet. It's the kind of type safety that pays dividends six months later when a junior dev extends the system and TypeScript catches what a code review might miss.

The pattern works because the compiler tracks which literal values the discriminant can still hold at each point in the control flow, and subtracts a case from that set every time you handle one. When the set is empty, the residual type is never — which is exactly what assertNever demands. Exhaustiveness isn't a convention you maintain; it's arithmetic the compiler does for you.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

TypeScript ships with 15+ utility types that let you derive new types from existing ones. Here's the full toolkit with the production patterns you'll actually reach for.
AdminAugust 2, 20265 min read
TypeScript is the default for serious JavaScript work now — but the real question is when the type overhead pays off, and which parts of your codebase genuinely don't need it.
AdminAugust 2, 20268 min read
Two pointers, one pass, O(n) — learn how the variable-size sliding window grows and shrinks to solve substring and subarray problems efficiently.
AdminAugust 2, 20264 min read