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.
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:
| Activity | Time |
|---|---|
| Refactor existing error handling to Result type | 1β3 days |
| Model domain events as union | 1 day |
| Add exhaustiveness checking to existing switches | Half a day |
Write assertNever + type predicate utilities | 1 hour |
See Also
- TypeScript Template Literal Types
- TypeScript Advanced Generics
- TypeScript Utility Types for React
- TypeScript Decorators
- React State Machines with XState
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 withOk/Errconstructors and chaining utilities- Domain event union for your business domain (workspace, billing, user events)
assertNeverexhaustiveness checking in all switch statements- Type predicate helpers for filtering discriminated union arrays
- Form state machine with
idle | submitting | success | errorphases
Talk to our team about TypeScript architecture β
Or explore our web development services.
About the Author
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.
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
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.