Discriminated Unions That Killed My Bugs
Optional fields are how TypeScript codebases drift into nullable-everywhere chaos. Discriminated unions are how they get rescued.
Most TypeScript bugs I find on code review are the same shape. A type is defined with optional fields. Some fields are only meaningful when other fields are set. Nothing enforces the relationship. Six months in, the codebase has foo?.bar?.baz ?? defaultBaz sprinkled defensively everywhere. The fix is older than TypeScript — discriminated unions.
1. The bad shape
type LoadResult = {
status: "loading" | "success" | "error";
data?: User;
error?: Error;
};
The compiler will let you read r.data when r.status === "error". It will let you read r.error when the request succeeded. Every consumer has to defensively check. Every consumer occasionally forgets.
2. The discriminated union
type LoadResult =
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: Error };
Same vocabulary. The compiler now refuses you:
function render(r: LoadResult) {
if (r.status === "success") {
return r.data.name; // OK — r.data is User
}
if (r.status === "error") {
return r.error.message; // OK — r.error is Error
}
return "loading...";
// and trying r.data here would be a compile error
}
The discriminator (status) is the field every variant has, with a literal type that distinguishes them. TypeScript narrows by it automatically.
3. Exhaustiveness checks
Add a new variant later. Every consumer should be updated. The compiler can guarantee that for you:
function render(r: LoadResult): string {
switch (r.status) {
case "loading": return "...";
case "success": return r.data.name;
case "error": return r.error.message;
default:
const _exhaustive: never = r;
return _exhaustive;
}
}
If you add { status: "cancelled" } to the union, the never assignment fails everywhere you forgot to handle it. The compiler hands you the bug list. This single pattern has caught more of my bugs than any test suite.
4. Where this shines
- API responses (success / error / loading).
- Domain state machines (draft / submitted / approved / rejected).
- Event payloads (UserCreated / UserUpdated / UserDeleted), with the payload shape varying.
- Anywhere "this field is only present when X" was your previous comment.
5. Idiomatic patterns
5.1 The constructor functions
const Result = {
loading: (): LoadResult => ({ status: "loading" }),
success: (data: User): LoadResult => ({ status: "success", data }),
failure: (error: Error): LoadResult => ({ status: "error", error }),
};
Callers write Result.success(user) instead of literal objects. Cheaper to refactor, more readable in tests.
5.2 The narrowing helpers
function isSuccess(r: LoadResult): r is Extract<LoadResult, { status: "success" }> {
return r.status === "success";
}
Useful when you want a one-line filter or guard outside of a switch.
6. When NOT to use this
- For variants that differ only in values, not shape.
{ kind: "small" | "large", size: number }is fine as-is. - When the discriminator would be artificial. Don't invent a
kindfield that nobody else needs just to satisfy the pattern.
The mental model
Optional fields say "this might be there." Discriminated unions say "this is exactly there when X." The first is convenient; the second is correct. Reach for the second any time the optional fields are correlated with each other or with a state.
Rules of thumb
- Anytime you write three or more
?:fields where some are only meaningful together, refactor to a union. - Always pair the union with an exhaustiveness check (
neverassertion). - The discriminator name conventions:
kind,type,status,tag. Pick one and be consistent.