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
acceptedAton 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: truewith 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.
Related Concepts
- 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.