04 Tagged unions 6 chapters

Theme 07 · Effect / Rust / OCaml-Style Design

Tagged unions

Explanation

Tagged unions

Plain Human Explanation

A tagged union is a way to say, “This value can be one of these exact cases, and each case has its own fields.”

The tag is usually a property like kind, type, or status. Once the code checks that tag, TypeScript knows which fields exist. That prevents a common product bug: guessing what happened from optional fields that might or might not be present.

For users, this means the product handles states like pending payment, failed import, invited member, or canceled subscription in a predictable way.

Technical Explanation

Use tagged unions when one concept has real variants. Each variant should have a required tag and only the fields that make sense for that case.

Then handle the union with a switch or if-branch that covers every case. Exhaustive handling means a new case forces call sites to decide what to do instead of silently falling through.

Why It Matters

  • User impact: fewer blank screens, wrong messages, and impossible mixed states.
  • Product behavior: each variant gets a named path and exact data.
  • Risk: optional-field objects let code accidentally combine states that should be separate.
  • Decision point: use this when different cases need different fields, copy, permissions, recovery, or side effects.

The Core Move

Replace one broad object with several named variants, then handle every variant deliberately.

Small Example

Tagged unions: Small Example

Bad TypeScript Example

type Invite = {
  email?: string;
  acceptedAt?: Date;
  declinedReason?: string;
};

export function inviteLabel(invite: Invite) {
  if (invite.acceptedAt) return "Accepted";
  if (invite.declinedReason) return "Declined";
  return `Pending for ${invite.email}`;
}
type Invite = {
  email?: string;
  acceptedAt?: Date;
  declinedReason?: string;
};

export function inviteLabel(
  invite: Invite,
) {
  if (invite.acceptedAt) return "Accepted";

  if (invite.declinedReason)
    return "Declined";

  return `Pending for ${invite.email}`;
}

Good TypeScript Example

type Invite =
  | { kind: "pending"; email: string; sentAt: Date }
  | { kind: "accepted"; email: string; acceptedAt: Date }
  | { kind: "declined"; email: string; reason: string };

export function inviteLabel(invite: Invite) {
  switch (invite.kind) {
    case "pending":
      return `Pending for ${invite.email}`;
    case "accepted":
      return `Accepted on ${invite.acceptedAt.toDateString()}`;
    case "declined":
      return `Declined: ${invite.reason}`;
  }
}
type Invite =
  | {
      kind: "pending";
      email: string;
      sentAt: Date;
    }
  | {
      kind: "accepted";
      email: string;
      acceptedAt: Date;
    }
  | {
      kind: "declined";
      email: string;
      reason: string;
    };

export function inviteLabel(
  invite: Invite,
) {
  switch (invite.kind) {
    case "pending":
      return `Pending for ${invite.email}`;
    case "accepted":
      return `Accepted on ${invite.acceptedAt.toDateString()}`;
    case "declined":
      return `Declined: ${invite.reason}`;
  }
}

What Changed

  • The bad version guesses invite state from optional fields.
  • The good version gives each invite state a required tag and exact fields.
  • The label logic can no longer ask for acceptedAt on a pending invite.

Realistic Example

Tagged unions: Realistic Example

This example uses import results. A successful import, a validation failure, and a duplicate upload need different data and different user messages.

Bad TypeScript Example

type ImportOutcome = {
  ok: boolean;
  importId?: string;
  rowCount?: number;
  error?: string;
  duplicateOf?: string;
};

export function importMessage(outcome: ImportOutcome) {
  if (outcome.ok) return `Imported ${outcome.rowCount} rows`;
  if (outcome.duplicateOf) return `Duplicate of ${outcome.duplicateOf}`;
  return outcome.error || "Import failed";
}
type ImportOutcome = {
  ok: boolean;
  importId?: string;
  rowCount?: number;
  error?: string;
  duplicateOf?: string;
};

export function importMessage(
  outcome: ImportOutcome,
) {
  if (outcome.ok)
    return `Imported ${outcome.rowCount} rows`;

  if (outcome.duplicateOf)
    return `Duplicate of ${outcome.duplicateOf}`;

  return outcome.error || "Import failed";
}

Good TypeScript Example

type ImportOutcome =
  | { kind: "imported"; importId: string; rowCount: number }
  | { kind: "validation-failed"; rowNumber: number; message: string }
  | { kind: "duplicate"; existingImportId: string };

export function importMessage(outcome: ImportOutcome) {
  switch (outcome.kind) {
    case "imported":
      return `Imported ${outcome.rowCount} rows`;
    case "validation-failed":
      return `Row ${outcome.rowNumber}: ${outcome.message}`;
    case "duplicate":
      return `This file was already imported with id ${outcome.existingImportId}`;
  }
}
type ImportOutcome =
  | {
      kind: "imported";
      importId: string;
      rowCount: number;
    }
  | {
      kind: "validation-failed";
      rowNumber: number;
      message: string;
    }
  | {
      kind: "duplicate";
      existingImportId: string;
    };

export function importMessage(
  outcome: ImportOutcome,
) {
  switch (outcome.kind) {
    case "imported":
      return `Imported ${outcome.rowCount} rows`;
    case "validation-failed":
      return `Row ${outcome.rowNumber}: ${outcome.message}`;
    case "duplicate":
      return `This file was already imported with id ${outcome.existingImportId}`;
  }
}

What Changed

  • The bad version allows mixed states like ok: true with an error message.
  • The good version gives each result exactly the data the UI needs.
  • Adding a new import outcome now forces the message function to handle it.

System Example

Tagged unions: System Example

At system scale, tagged unions are useful when workflow states need different database writes, emails, logs, or UI responses.

Larger System-Level Bad TypeScript Example

type PaymentState = {
  status: string;
  chargeId?: string;
  failureCode?: string;
  retryAt?: Date;
  receiptEmail?: string;
};

type PaymentPorts = {
  db: {
    save(state: PaymentState): Promise<void>;
  };
  email: {
    send(to: string, subject: string): Promise<void>;
  };
};

export async function finishPayment(state: PaymentState, ports: PaymentPorts) {
  await ports.db.save(state);

  if (state.status === "paid") {
    await ports.email.send(state.receiptEmail || "", "Receipt");
  }
}
type PaymentState = {
  status: string;
  chargeId?: string;
  failureCode?: string;
  retryAt?: Date;
  receiptEmail?: string;
};

type PaymentPorts = {
  db: {
    save(
      state: PaymentState,
    ): Promise<void>;
  };
  email: {
    send(
      to: string,
      subject: string,
    ): Promise<void>;
  };
};

export async function finishPayment(
  state: PaymentState,
  ports: PaymentPorts,
) {
  await ports.db.save(state);

  if (state.status === "paid") {
    await ports.email.send(
      state.receiptEmail || "",
      "Receipt",
    );
  }
}

Larger System-Level Good TypeScript Example

type PaymentState =
  | { kind: "paid"; chargeId: string; receiptEmail: string }
  | { kind: "failed"; failureCode: "card_declined" | "expired_card"; retryAt: Date }
  | { kind: "canceled"; canceledBy: "user" | "system" };

type PaymentPorts = {
  payments: {
    savePaid(chargeId: string): Promise<void>;
    saveFailed(code: string, retryAt: Date): Promise<void>;
    saveCanceled(reason: string): Promise<void>;
  };
  email: {
    send(to: string, subject: string): Promise<void>;
  };
};

export async function finishPayment(state: PaymentState, ports: PaymentPorts) {
  switch (state.kind) {
    case "paid":
      await ports.payments.savePaid(state.chargeId);
      await ports.email.send(state.receiptEmail, "Receipt");
      return;
    case "failed":
      await ports.payments.saveFailed(state.failureCode, state.retryAt);
      return;
    case "canceled":
      await ports.payments.saveCanceled(state.canceledBy);
      return;
  }
}
type PaymentState =
  | {
      kind: "paid";
      chargeId: string;
      receiptEmail: string;
    }
  | {
      kind: "failed";
      failureCode:
        | "card_declined"
        | "expired_card";
      retryAt: Date;
    }
  | {
      kind: "canceled";
      canceledBy: "user" | "system";
    };

type PaymentPorts = {
  payments: {
    savePaid(
      chargeId: string,
    ): Promise<void>;
    saveFailed(
      code: string,
      retryAt: Date,
    ): Promise<void>;
    saveCanceled(
      reason: string,
    ): Promise<void>;
  };
  email: {
    send(
      to: string,
      subject: string,
    ): Promise<void>;
  };
};

export async function finishPayment(
  state: PaymentState,
  ports: PaymentPorts,
) {
  switch (state.kind) {
    case "paid":
      await ports.payments.savePaid(
        state.chargeId,
      );
      await ports.email.send(
        state.receiptEmail,
        "Receipt",
      );
      return;
    case "failed":
      await ports.payments.saveFailed(
        state.failureCode,
        state.retryAt,
      );
      return;
    case "canceled":
      await ports.payments.saveCanceled(
        state.canceledBy,
      );
      return;
  }
}

What Changed

  • The bad version lets one loose object describe paid, failed, and canceled payments.
  • The good version gives each payment state a different persistence path.
  • The workflow cannot send a receipt unless it is handling the paid variant.

When To Use It

Tagged unions: When To Use It

Use This When

  • One concept has cases with different fields or behavior.
  • The current code guesses state from optional fields, booleans, or broad strings.
  • Adding a new case should force routes, jobs, UI, or tests to make a deliberate choice.

Avoid This When

  • Every case has the same data and behavior.
  • A simple enum is enough because the state does not carry case-specific fields.
  • The union would hide a more important lifecycle or state-machine rule.

Tradeoffs

Tagged unions make variants explicit, but they can be verbose for tiny values. Use them when the extra names prevent real confusion about what data is available in each case.

  • OCaml-ish domain modules
  • Errors-as-values thinking
  • Type safety maximalist, pragmatic

Practice Prompt

Tagged unions: Practice Prompt

Beginner Exercise

Find one type with several optional fields. Write down the real cases that optional fields are trying to represent.

Intermediate Exercise

Replace that type with a tagged union. Update one function to switch on the tag instead of checking optional fields.

Stretch Exercise

Add one new variant and let TypeScript show you which call sites need a product decision.

Reflection Question

Does each variant carry only the fields that make sense for that exact case?

Suggest an edit

Leave a private editorial note. This creates a GitHub issue for this curriculum page.