Back to Blog

TypeScript Discriminated Unions: Pattern Matching, Exhaustiveness Checking, and Real-World Patterns

Master TypeScript discriminated unions for safer, more expressive code. Covers union narrowing, exhaustiveness checking with never, pattern matching utilities, Result and Option types, event modeling, and common pitfalls.

Viprasol Tech Team
April 22, 2027
12 min read

Discriminated unions are one of TypeScript's most powerful features β€” they let you model state that can only be in one of several well-defined shapes, with the compiler enforcing that you handle every case. They eliminate entire classes of bugs: impossible states, unhandled cases, and the classic "TypeError: Cannot read properties of undefined" from forgetting to check a nullable field.

This guide covers the patterns, utilities, and real-world applications that make discriminated unions indispensable.

The Basics

// A discriminated union: each variant has a literal "kind" field (the discriminant)
type Shape =
  | { kind: "circle";    radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle";  base: number;  height: number };

// TypeScript narrows the type inside each case
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 0.5 * shape.base * shape.height;
  }
  // TypeScript error if you add a new variant and forget to handle it!
}

Exhaustiveness Checking with never

// The never trick: if you reach the default case, TypeScript
// errors if any variant is unhandled
function assertNever(value: never, message?: string): never {
  throw new Error(message ?? `Unhandled case: ${JSON.stringify(value)}`);
}

function describeShape(shape: Shape): string {
  switch (shape.kind) {
    case "circle":
      return `Circle with radius ${shape.radius}`;
    case "rectangle":
      return `Rectangle ${shape.width}Γ—${shape.height}`;
    case "triangle":
      return `Triangle base ${shape.base}, height ${shape.height}`;
    default:
      return assertNever(shape);
      // If you add kind: "polygon" to Shape without handling it here,
      // TypeScript gives: Argument of type 'Polygon' is not assignable to parameter of type 'never'
  }
}

🌐 Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different β€” senior engineers only, direct Slack access, and a 5.0β˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript β€” production-grade stack
  • Fixed-price contracts β€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Result Type for Error Handling

// A typed alternative to throwing exceptions
type Ok<T>  = { readonly ok: true;  value: T };
type Err<E> = { readonly ok: false; error: E };
type Result<T, E = Error> = Ok<T> | Err<E>;

// Constructor helpers
const Ok  = <T>(value: T): Ok<T>   => ({ ok: true,  value });
const Err = <E>(error: E): Err<E>  => ({ ok: false, error });

// Usage: never throws, caller decides what to do with errors
async function fetchUser(id: string): Promise<Result<User, "not_found" | "db_error">> {
  try {
    const user = await prisma.user.findUnique({ where: { id } });
    if (!user) return Err("not_found");
    return Ok(user);
  } catch {
    return Err("db_error");
  }
}

// Caller: exhaustive handling
const result = await fetchUser(userId);

if (result.ok) {
  // TypeScript knows: result.value is User
  console.log(result.value.email);
} else {
  // TypeScript knows: result.error is "not_found" | "db_error"
  switch (result.error) {
    case "not_found":
      return NextResponse.json({ error: "User not found" }, { status: 404 });
    case "db_error":
      return NextResponse.json({ error: "Database error" }, { status: 500 });
    default:
      return assertNever(result.error);
  }
}

Async Result Utilities

// Chain result operations without nested if/else
type AsyncResult<T, E = Error> = Promise<Result<T, E>>;

async function mapResult<T, U, E>(
  result: Result<T, E>,
  fn: (value: T) => Promise<U> | U
): Promise<Result<U, E>> {
  if (!result.ok) return result;
  try {
    return Ok(await fn(result.value));
  } catch (err) {
    // Don't swallow β€” rethrow unexpected errors
    throw err;
  }
}

async function flatMapResult<T, U, E>(
  result: Result<T, E>,
  fn: (value: T) => AsyncResult<U, E>
): AsyncResult<U, E> {
  if (!result.ok) return result;
  return fn(result.value);
}

// Usage: chained operations
const userResult = await fetchUser(userId);
const profileResult = await flatMapResult(userResult, (user) =>
  fetchProfile(user.profileId)
);
const avatarResult = await mapResult(profileResult, (profile) =>
  buildAvatarUrl(profile.avatarKey)
);

πŸš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4–8 weeks, full platforms in 3–5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Domain Event Modeling

// Model all domain events as a discriminated union
// Event sourcing, webhooks, and message queues all benefit from this pattern

type WorkspaceEvent =
  | { type: "workspace.created";    workspaceId: string; name: string; ownerId: string }
  | { type: "workspace.renamed";    workspaceId: string; oldName: string; newName: string }
  | { type: "workspace.deleted";    workspaceId: string; deletedBy: string }
  | { type: "member.invited";       workspaceId: string; email: string; role: string; invitedBy: string }
  | { type: "member.joined";        workspaceId: string; userId: string; role: string }
  | { type: "member.removed";       workspaceId: string; userId: string; removedBy: string }
  | { type: "subscription.upgraded"; workspaceId: string; fromPlan: string; toPlan: string }
  | { type: "subscription.cancelled"; workspaceId: string; reason: string };

// All events share a base structure
type BaseEvent = { occurredAt: string; correlationId: string };
type DomainEvent = WorkspaceEvent & BaseEvent;

// Event handler: exhaustive switch
function handleWorkspaceEvent(event: DomainEvent): void {
  switch (event.type) {
    case "workspace.created":
      sendWelcomeEmail(event.ownerId, event.workspaceId);
      break;
    case "workspace.renamed":
      updateSearchIndex(event.workspaceId, event.newName);
      break;
    case "member.invited":
      sendInviteEmail(event.email, event.workspaceId, event.invitedBy);
      break;
    case "member.joined":
      notifyWorkspaceAdmins(event.workspaceId, event.userId);
      break;
    case "subscription.upgraded":
      provisionPlanFeatures(event.workspaceId, event.toPlan);
      break;
    case "subscription.cancelled":
      scheduleDowngrade(event.workspaceId);
      break;
    case "workspace.deleted":
    case "member.removed":
      // No async side-effects needed β€” audit log handles it
      break;
    default:
      assertNever(event);
  }
}

API Response Types

// Type the full range of API responses
type ApiResponse<T> =
  | { status: "success"; data: T }
  | { status: "error";   code: string; message: string }
  | { status: "loading" };  // Used in React state

// React hook using discriminated union for request state
import { useState, useEffect } from "react";

function useApiData<T>(url: string): ApiResponse<T> {
  const [state, setState] = useState<ApiResponse<T>>({ status: "loading" });

  useEffect(() => {
    fetch(url)
      .then((res) => res.json())
      .then((data) => setState({ status: "success", data }))
      .catch((err) => setState({
        status: "error",
        code: "fetch_error",
        message: err.message,
      }));
  }, [url]);

  return state;
}

// Usage: the component renders differently for each state
function UserProfile({ userId }: { userId: string }) {
  const state = useApiData<User>(`/api/users/${userId}`);

  if (state.status === "loading") return <Skeleton />;
  if (state.status === "error") return <ErrorBanner message={state.message} />;

  // TypeScript knows: state.data is User here
  return <div>{state.data.name}</div>;
}

Form State Machine

// Model form submission as a state machine with discriminated union
type FormState<T, E = string> =
  | { phase: "idle" }
  | { phase: "submitting" }
  | { phase: "success"; data: T }
  | { phase: "error"; error: E };

function useFormState<T, E = string>() {
  const [state, setState] = useState<FormState<T, E>>({ phase: "idle" });

  async function submit(fn: () => Promise<T>): Promise<void> {
    setState({ phase: "submitting" });
    try {
      const data = await fn();
      setState({ phase: "success", data });
    } catch (err) {
      setState({ phase: "error", error: (err as Error).message as E });
    }
  }

  function reset() {
    setState({ phase: "idle" });
  }

  return { state, submit, reset };
}

// Usage:
function InviteForm({ workspaceId }: { workspaceId: string }) {
  const { state, submit, reset } = useFormState<{ inviteId: string }>();

  return (
    <form onSubmit={async (e) => {
      e.preventDefault();
      await submit(() =>
        fetch(`/api/invites`, { method: "POST", body: ... }).then(r => r.json())
      );
    }}>
      {state.phase === "error" && (
        <p className="text-red-600">{state.error}</p>
      )}
      {state.phase === "success" && (
        <p className="text-green-600">Invite sent!</p>
      )}
      <button disabled={state.phase === "submitting"} type="submit">
        {state.phase === "submitting" ? "Sending…" : "Send invite"}
      </button>
    </form>
  );
}

Narrowing Without Switch

// Type predicates for discriminated union narrowing
function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
  return result.ok === true;
}

function isErr<T, E>(result: Result<T, E>): result is Err<E> {
  return result.ok === false;
}

// Usage in arrays: filter to only success results
const results: Result<User>[] = await Promise.all(ids.map(fetchUser));
const users: User[] = results.filter(isOk).map((r) => r.value);
const errors = results.filter(isErr).map((r) => r.error);

Common Pitfalls

// ❌ PITFALL 1: Forgetting the discriminant field
// Without a shared literal field, TypeScript can't narrow
type Bad =
  | { name: string; age: number }      // No discriminant
  | { name: string; email: string };   // TypeScript can't tell these apart

// βœ… Always add a literal discriminant
type Good =
  | { type: "person"; name: string; age: number }
  | { type: "contact"; name: string; email: string };

// ❌ PITFALL 2: Using string instead of string literal for discriminant
type AlsoBad =
  | { type: string; data: User }      // type: string β€” not narrowable
  | { type: string; data: Product };

// βœ… Use string literals
type GoodEvent =
  | { type: "user_created";    data: User }
  | { type: "product_updated"; data: Product };

// ❌ PITFALL 3: Not handling the default case (no exhaustiveness check)
function handleEvent(event: GoodEvent): void {
  if (event.type === "user_created") {
    createUser(event.data);
  }
  // What if event.type === "product_updated"? Silently ignored!
}

// βœ… Exhaustive switch with assertNever
function handleEventSafe(event: GoodEvent): void {
  switch (event.type) {
    case "user_created":    createUser(event.data);    break;
    case "product_updated": updateProduct(event.data); break;
    default: assertNever(event);
  }
}

Cost and Timeline

Discriminated unions are a zero-cost abstraction β€” they compile away entirely. The investment is developer education and code review discipline:

ActivityTime
Refactor existing error handling to Result type1–3 days
Model domain events as union1 day
Add exhaustiveness checking to existing switchesHalf a day
Write assertNever + type predicate utilities1 hour

See Also


Working With Viprasol

Discriminated unions are one of the first things we introduce when working with TypeScript codebases that have brittle error handling or implicit nullable states. They make impossible states unrepresentable and turn runtime bugs into compile-time errors. Our team uses Result types, domain event unions, and form state machines as standard patterns across all TypeScript projects.

What we deliver:

  • Result<T, E> type with Ok/Err constructors and chaining utilities
  • Domain event union for your business domain (workspace, billing, user events)
  • assertNever exhaustiveness checking in all switch statements
  • Type predicate helpers for filtering discriminated union arrays
  • Form state machine with idle | submitting | success | error phases

Talk to our team about TypeScript architecture β†’

Or explore our web development services.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms β€” we build it all with Next.js and React.

Free consultation β€’ No commitment β€’ Response within 24 hours

Viprasol Β· Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse β‰₯90 scores, mobile-first design, and full source code ownership. Senior engineers only β€” from architecture through deployment.