TypeScript Discriminated Unions: Replace All Your if-else Chains
Discriminated unions are the most underappreciated pattern in TypeScript. Learn how they eliminate impossible states, simplify complex logic, and make your code self-documenting.
If your TypeScript code is full of if-else chains checking object shapes, optional properties, or string flags, there's a better way. Discriminated unions give TypeScript the power to narrow types automatically based on a single tag field — and they eliminate entire categories of bugs by making invalid states unrepresentable.
What Is a Discriminated Union?
A discriminated union is a union of types that share a common literal field (the "discriminant"). TypeScript uses this field to narrow the type inside control flow:
// Instead of one type with lots of optional fields...
// BAD: What does it mean when status is "error" but data exists?
interface ApiResponseBad {
status: "loading" | "success" | "error";
data?: User[];
error?: string;
retryAfter?: number;
}
// GOOD: Each state is its own type — impossible states are unrepresentable
type ApiResponse =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User[] }
| { status: "error"; error: string; retryAfter?: number };
function renderResponse(response: ApiResponse) {
switch (response.status) {
case "idle":
return <p>Ready to fetch</p>;
case "loading":
return <Spinner />;
case "success":
// TypeScript KNOWS data exists here
return <UserList users={response.data} />;
case "error":
// TypeScript KNOWS error exists here
return <Alert message={response.error} />;
}
}
Notice: you never need to check if (response.data) — TypeScript has already narrowed the type based on the status field.
Pattern 1: State Machines for Complex UI
Modal dialogs, multi-step forms, and wizard flows are natural fits for discriminated unions:
type CheckoutState =
| { step: "cart"; items: CartItem[] }
| { step: "shipping"; items: CartItem[]; address: Address }
| { step: "payment"; items: CartItem[]; address: Address; method: PaymentMethod }
| { step: "confirmation"; orderId: string }
| { step: "failed"; error: string; canRetry: boolean };
function CheckoutFlow({ state }: { state: CheckoutState }) {
switch (state.step) {
case "cart":
return <CartView items={state.items} />;
case "shipping":
return <ShippingForm address={state.address} />;
case "payment":
return <PaymentForm method={state.method} />;
case "confirmation":
return <OrderConfirmation orderId={state.orderId} />;
case "failed":
return <ErrorView error={state.error} canRetry={state.canRetry} />;
}
}
// Transitions are type-safe — you can't skip steps
function goToShipping(state: Extract<CheckoutState, { step: "cart" }>, address: Address): CheckoutState {
return { step: "shipping", items: state.items, address };
}
Pattern 2: Server Action Results
In Next.js Server Actions, discriminated unions make the response contract explicit:
"use server";
type ActionResult<T = void> =
| { success: true; data: T }
| { success: false; error: string; field?: string };
export async function createPost(formData: FormData): Promise<ActionResult<{ slug: string }>> {
const title = formData.get("title") as string;
if (!title || title.length < 3) {
return { success: false, error: "Title must be at least 3 characters", field: "title" };
}
try {
const post = await prisma.blogPost.create({
data: { title, slug: slugify(title), authorId: session.user.id },
});
return { success: true, data: { slug: post.slug } };
} catch {
return { success: false, error: "Failed to create post" };
}
}
// Client component
"use client";
function CreatePostForm() {
const [state, action] = useActionState(createPost, null);
return (
<form action={action}>
<input name="title" />
{state && !state.success && (
<p className="text-red-500">{state.error}</p>
)}
{state?.success && (
<p>Created! Slug: {state.data.slug}</p>
)}
<button type="submit">Create</button>
</form>
);
}
Exhaustiveness Checking with never
The killer feature of discriminated unions is compile-time exhaustiveness. If you add a new variant, TypeScript will tell you every place that needs to handle it:
function assertNever(x: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}
function getStatusColor(status: ApiResponse["status"]): string {
switch (status) {
case "idle": return "gray";
case "loading": return "blue";
case "success": return "green";
case "error": return "red";
default: return assertNever(status);
// If you add a new status, TypeScript errors HERE
}
}
Takeaways
- Replace interfaces with optional fields with discriminated unions — make impossible states unrepresentable
- Use them for UI state machines, API responses, form steps, and action results
- Add
assertNeverin default branches to catch missing cases at compile time - Use
Extract<Union, { tag: "value" }>to pull specific variants from a union
Discriminated unions are arguably TypeScript's most powerful feature for modeling real-world domain logic. Once you start using them, you'll wonder how you ever managed complex state with optional properties and if-else chains.
Admin
Cal.com
Open source scheduling — tự host booking system, thay thế Calendly. Free & privacy-first.
Bình luận (0)
Đăng nhập để bình luận
Chưa có bình luận nào. Hãy là người đầu tiên!