XplormityXplormity
HomeHandbooks
Browse
Xplormity

TLDR developer handbooks for
seasoned developers.

Handbooks

RustNestJSNext.jsGitDockerTypeScriptReactNode.jsDSASQLSystem DesignTailwind CSS

Site

HomeHandbooksAboutPrivacyTerms

Connect

GitHubTwitterLinkedIn

© 2026 Xplormity. All rights reserved.

HandbooksTypeScriptType Narrowing & Discriminated Unions

Type Narrowing & Discriminated Unions

narrowingdiscriminated-unionspatternshandbook

TL;DR

  • Narrowing = TypeScript eliminating types based on runtime checks.
  • Discriminated unions = unions with a shared literal property (the "tag") that TS uses for exhaustive switching.
  • never type = "this should be impossible" — use for exhaustiveness checking.
  • These patterns are the #1 way to write safe TypeScript without any or type assertions.

Step 1: How Narrowing Works

Narrowing is TypeScript's killer feature — it's what makes the type system practical rather than just annotations. The compiler tracks control flow and eliminates impossible types after each check. Without narrowing, you'd need explicit type assertions everywhere (value as string), which are unsafe. Narrowing makes TypeScript genuinely catch bugs: if you forget a null check, the type system shows the error before runtime. Understanding narrowing mechanics (typeof, truthiness, equality, in, instanceof) is essential for writing TypeScript that "just works" without any escape hatches.

TypeScript tracks which types are possible at each point in your code. Every control flow statement that checks a type narrows it:

function process(value: string | number | null) {
  // Here: value is string | number | null

  if (value === null) {
    // Here: value is null
    return;
  }
  // Here: value is string | number (null eliminated)

  if (typeof value === "string") {
    // Here: value is string
    console.log(value.toUpperCase());
  } else {
    // Here: value is number
    console.log(value.toFixed(2));
  }
}

All Narrowing Mechanisms

Mechanism Example What it narrows
typeof typeof x === "string" Primitives
instanceof x instanceof Date Class instances
in operator "name" in x Objects with property
Equality x === null Specific values
Truthiness if (x) Removes null, undefined, 0, ""
Assignment x = "hello" To assigned type
Type predicate isString(x) Custom narrowing

Step 2: Type Guards (Custom Narrowing)

Type guards let you write runtime checks that TypeScript understands at the type level. They exist because typeof only handles primitives and instanceof only handles classes — but real code works with interfaces, union types, and complex shapes that need custom validation logic. Every API response handler, form validator, and event processor uses type guards to safely distinguish between different shapes of data without resorting to any or unsafe casts.

typeof Guards

function double(x: string | number): string | number {
  if (typeof x === "string") {
    return x + x;      // string concatenation
  }
  return x * 2;        // number multiplication
}

instanceof Guards

class ApiError extends Error {
  constructor(public status: number, message: string) {
    super(message);
  }
}

function handleError(error: Error | ApiError) {
  if (error instanceof ApiError) {
    // Narrowed to ApiError — can access .status
    console.log(`HTTP ${error.status}: ${error.message}`);
  } else {
    // Just a regular Error
    console.log(error.message);
  }
}

in Operator Guards

interface Bird { fly(): void; layEgg(): void; }
interface Fish { swim(): void; layEgg(): void; }

function move(animal: Bird | Fish) {
  if ("fly" in animal) {
    animal.fly();    // Narrowed to Bird
  } else {
    animal.swim();   // Narrowed to Fish
  }
}

Step 3: User-Defined Type Predicates

Type predicates (x is SomeType) were invented because TypeScript can't automatically infer narrowing from helper functions. If you extract a check into a separate function, TypeScript loses the narrowing unless you annotate the return type as a predicate. This pattern is essential for reusable validation logic — checking if an API response is valid, if a user has admin permissions, or if a value from unknown is a specific shape. They're also how libraries like Zod and io-ts provide type-safe parsing.

When built-in narrowing isn't enough, write a type predicate function:

// The return type `x is string` tells TS to narrow in the caller
function isString(x: unknown): x is string {
  return typeof x === "string";
}

function process(value: unknown) {
  if (isString(value)) {
    // value is now string! TS narrowed it.
    console.log(value.toUpperCase());
  }
}

// Practical: Validate API response
interface User {
  id: number;
  name: string;
  email: string;
}

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

async function fetchUser(id: string) {
  const res = await fetch(`/api/users/${id}`);
  const data: unknown = await res.json();

  if (isUser(data)) {
    // data is User — fully typed, safely narrowed
    console.log(data.name);
  }
}

asserts predicates

function assertDefined<T>(value: T | null | undefined, msg?: string): asserts value is T {
  if (value === null || value === undefined) {
    throw new Error(msg ?? "Value is not defined");
  }
}

function process(user: User | null) {
  assertDefined(user, "User must exist");
  // After this line, user is User (not null)
  console.log(user.name);
}

Step 4: Discriminated Unions

Discriminated unions are arguably TypeScript's most powerful modeling tool. They exist because real domains have mutually exclusive states: an HTTP response is either success or error, a payment is pending or completed or failed. Traditional OOP uses inheritance for this, but discriminated unions with a literal "tag" property let TypeScript narrow types exhaustively in switch statements. This pattern replaced entire categories of runtime bugs and is how React models actions in reducers, how tRPC models router outputs, and how every well-typed state machine works.

The most important pattern in TypeScript for modeling domain logic safely.

The Pattern

  1. Define a union where each member has a shared literal property (the discriminant/tag)
  2. Switch on that property — TS narrows to the correct member
// Step 1: Define tagged types
type ApiResponse =
  | { status: "success"; data: User; timestamp: number }
  | { status: "error"; error: string; code: number }
  | { status: "loading" };

// Step 2: Switch on the tag
function handleResponse(response: ApiResponse) {
  switch (response.status) {
    case "success":
      // TS knows: { status: "success"; data: User; timestamp: number }
      console.log(response.data.name);
      console.log(response.timestamp);
      break;

    case "error":
      // TS knows: { status: "error"; error: string; code: number }
      console.log(`Error ${response.code}: ${response.error}`);
      break;

    case "loading":
      // TS knows: { status: "loading" } — no other properties
      console.log("Loading...");
      break;
  }
}

Step 5: Exhaustiveness Checking with never

Exhaustiveness checking ensures you handle every possible case in a union — and more importantly, it makes the compiler error when someone adds a new case you haven't handled. Without it, adding a new variant to a union silently falls through unhandled. The never type trick (assigning the narrowed value to never) catches these at compile time rather than production. This is why discriminated unions + exhaustiveness are the recommended pattern for state machines, reducers, and protocol handlers.

The never type represents "this code should never execute." Use it to ensure you handle ALL cases:

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 0.5 * shape.base * shape.height;
    default:
      // If all cases handled, shape is 'never' here
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

// If you add a new shape kind and forget to handle it,
// TS gives a compile error at the `never` assignment! 🎯

Helper function for exhaustiveness

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

// Use in switch default
default:
  return assertNever(shape);

Step 6: Real-World Patterns

These patterns bring narrowing and discriminated unions together in production scenarios. State machines model UI states (loading, error, success) so you can't accidentally access data before it's loaded. API response typing ensures error responses and success responses are handled differently. These patterns are what separate "TypeScript with any everywhere" from TypeScript that genuinely prevents bugs — they're used in every modern React app, backend service, and CLI tool.

Pattern 1: State Machines

type AuthState =
  | { state: "idle" }
  | { state: "authenticating"; email: string }
  | { state: "authenticated"; user: User; token: string }
  | { state: "error"; message: string; retryCount: number };

type AuthAction =
  | { type: "LOGIN"; email: string; password: string }
  | { type: "LOGIN_SUCCESS"; user: User; token: string }
  | { type: "LOGIN_FAILURE"; message: string }
  | { type: "LOGOUT" };

function authReducer(state: AuthState, action: AuthAction): AuthState {
  switch (action.type) {
    case "LOGIN":
      return { state: "authenticating", email: action.email };
    case "LOGIN_SUCCESS":
      return { state: "authenticated", user: action.user, token: action.token };
    case "LOGIN_FAILURE":
      return {
        state: "error",
        message: action.message,
        retryCount: state.state === "error" ? state.retryCount + 1 : 1,
      };
    case "LOGOUT":
      return { state: "idle" };
  }
}

Pattern 2: Result Type (Error Handling without Exceptions)

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

function divide(a: number, b: number): Result<number, string> {
  if (b === 0) return { ok: false, error: "Division by zero" };
  return { ok: true, value: a / b };
}

const result = divide(10, 2);
if (result.ok) {
  console.log(result.value);  // TS knows value exists
} else {
  console.log(result.error);  // TS knows error exists
}

Pattern 3: Event System

type AppEvent =
  | { type: "user.created"; payload: { id: string; name: string } }
  | { type: "user.deleted"; payload: { id: string } }
  | { type: "post.published"; payload: { postId: string; authorId: string } };

type EventHandler<T extends AppEvent["type"]> = (
  payload: Extract<AppEvent, { type: T }>["payload"]
) => void;

// Handler is fully typed based on event name
const handler: EventHandler<"user.created"> = (payload) => {
  console.log(payload.name); // ✅ TS knows about name
};

Interview Questions

  1. What's a discriminated union?

    • A union type where each member has a common literal property (the discriminant). TypeScript uses it to narrow the type in switch/if statements.
  2. How do you enforce exhaustive handling?

    • Use a never type in the default case. If a new variant is added but not handled, TS gives a compile error because the value can't be assigned to never.
  3. What's the difference between unknown and any?

    • any disables type-checking. unknown is type-safe: you MUST narrow it before using it. Always prefer unknown for values of truly unknown type.
  4. When would you use a type predicate vs instanceof?

    • instanceof only works with classes. Type predicates work with interfaces, unions, and any custom validation logic.
TypeScript 7.0 — The Go RewriteDecorators & Design Patterns

On this page

  • TL;DR
  • Step 1: How Narrowing Works
  • All Narrowing Mechanisms
  • Step 2: Type Guards (Custom Narrowing)
  • typeof Guards
  • instanceof Guards
  • in Operator Guards
  • Step 3: User-Defined Type Predicates
  • asserts predicates
  • Step 4: Discriminated Unions
  • The Pattern
  • Step 5: Exhaustiveness Checking with never
  • Helper function for exhaustiveness
  • Step 6: Real-World Patterns
  • Pattern 1: State Machines
  • Pattern 2: Result Type (Error Handling without Exceptions)
  • Pattern 3: Event System
  • Interview Questions