Bỏ qua để vào nội dung chính
TypeScript Discriminated Unions: Replace All Your if-else Chains

TypeScript Discriminated Unions: Replace All Your if-else Chains

Bởi Zoe Taylor
11 thg 3, 20266 phút đọc

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 assertNever in 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.

Không spam, hủy đăng ký bất kỳ lúc nào.

Bài viết liên quan