Discriminated Unions and Tagged Types in TypeScript


What Are Discriminated Unions
A regular union type like string | number forces you to use type guards — typeof, instanceof, or custom predicates — to figure out which branch you're in. That works, but it scales poorly when your union has many members with complex shapes.
Discriminated unions solve this with a simple convention: every member of the union shares a property with the same name but a different literal type. That shared property is the discriminant (sometimes called a tag). TypeScript checks the discriminant at compile time and uses it to narrow the full type automatically — no extra type guard functions needed.
In the last post, you briefly saw this pattern with status: "loading" | "success" | "error". In this post you'll go deeper: building precise state models, enabling exhaustive checks that catch every missing case, and applying the pattern to real-world scenarios like async data, event systems, and command handlers.
Also Called Tagged Unions
Discriminated unions go by several names: tagged unions, disjoint unions, and algebraic data types (ADTs). All refer to the same concept — a union where each member carries a unique identifier that allows safe narrowing. The term "tagged" comes from the idea that each variant is tagged with a literal that names it.
The Discriminant Property
A discriminated union needs three things: a union type, a shared property on every member, and a unique literal type for that property on each member.
Here's the simplest possible example:
type Circle = {
kind: "circle";
radius: number;
};
type Rectangle = {
kind: "rectangle";
width: number;
height: number;
};
type Triangle = {
kind: "triangle";
base: number;
height: number;
};
type Shape = Circle | Rectangle | Triangle;kind is the discriminant. Each member has it, and each has a unique literal value. Now TypeScript can narrow on it:
function area(shape: Shape): number {
if (shape.kind === "circle") {
// TypeScript knows: shape is Circle
return Math.PI * shape.radius ** 2;
}
if (shape.kind === "rectangle") {
// TypeScript knows: shape is Rectangle
return shape.width * shape.height;
}
// TypeScript knows: shape is Triangle
return 0.5 * shape.base * shape.height;
}No as casts, no custom type predicates, no in checks. TypeScript narrows Shape to Circle, Rectangle, or Triangle purely on the equality check shape.kind === "circle". Access a property that doesn't exist on the narrowed type and TypeScript flags it immediately.
Switch statements work equally well and often read more clearly for discriminated unions:
function describe(shape: Shape): string {
switch (shape.kind) {
case "circle":
return `Circle with radius ${shape.radius}`;
case "rectangle":
return `Rectangle ${shape.width}×${shape.height}`;
case "triangle":
return `Triangle with base ${shape.base} and height ${shape.height}`;
}
}Exhaustive Checking with never
The real power of discriminated unions reveals itself when you add a new member. TypeScript can tell you every place in your codebase that doesn't handle it yet — at compile time, before you ship.
The technique uses the never type. At the end of a switch statement, after all known cases are handled, the remaining type should be never — meaning no value can reach that point. If it isn't never, you've missed a case:
function assertNever(value: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
default:
return assertNever(shape); // Error if any case is missing
}
}Now add a new member to Shape:
type Ellipse = {
kind: "ellipse";
semiMajor: number;
semiMinor: number;
};
type Shape = Circle | Rectangle | Triangle | Ellipse;TypeScript immediately reports an error on the assertNever(shape) line: Argument of type 'Ellipse' is not assignable to parameter of type 'never'. The compiler pinpoints every switch statement that needs updating. No grep, no runtime crash — just a compiler error pointing you directly to the gap.
assertNever Doubles as a Runtime Safety Net
The assertNever function isn't just a type trick — it also throws at runtime
if a value reaches the default branch. This can happen when data comes from
an external source (API, database) and contains a variant your code doesn't
recognise yet. The thrown error surfaces the problem immediately rather than
silently producing wrong output.
Modelling Application State
Discriminated unions are one of the best tools for modelling asynchronous state. The traditional approach — boolean flags like isLoading, isError, isSuccess — allows impossible combinations: what does isLoading: true, isError: true mean? Discriminated unions make impossible states unrepresentable:
type AsyncData<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
// Usage with a specific type
type UserState = AsyncData<User>;
function renderUser(state: UserState): string {
switch (state.status) {
case "idle":
return "No data loaded yet";
case "loading":
return "Loading...";
case "success":
// TypeScript knows: state.data is User
return `Welcome, ${state.data.name}`;
case "error":
// TypeScript knows: state.error is Error
return `Failed: ${state.error.message}`;
default:
return assertNever(state);
}
}data only exists when status === "success". error only exists when status === "error". You can never accidentally access state.data in the loading branch — TypeScript won't allow it.
Make Impossible States Unrepresentable
This phrase — coined by Richard Feldman — captures why discriminated unions matter. When boolean flags represent state, you can set contradictory combinations. When a discriminated union represents state, each variant is its own type. The invalid combinations don't exist in the type system, so you can't accidentally create them.
Nested Discriminated Unions
Discriminated unions compose well. You can nest them to model more complex scenarios — for example, a network request that can fail in different ways:
type NetworkError =
| { kind: "timeout"; durationMs: number }
| { kind: "unauthorized"; redirectTo: string }
| { kind: "notFound"; resource: string }
| { kind: "serverError"; statusCode: number; message: string };
type FetchResult<T> =
| { status: "success"; data: T }
| { status: "error"; error: NetworkError };
function handleFetch<T>(result: FetchResult<T>): void {
if (result.status === "error") {
// TypeScript knows: result.error is NetworkError
switch (result.error.kind) {
case "timeout":
console.log(`Timed out after ${result.error.durationMs}ms`);
break;
case "unauthorized":
window.location.href = result.error.redirectTo;
break;
case "notFound":
console.log(`Resource not found: ${result.error.resource}`);
break;
case "serverError":
console.log(`${result.error.statusCode}: ${result.error.message}`);
break;
default:
assertNever(result.error);
}
}
}Each level of the nested union is narrowed independently. TypeScript tracks both result.status and result.error.kind without losing information.
Real-World Patterns
Discriminated unions shine in event-driven systems, command handlers, and anywhere you process different variants of a message:
type UserEvent =
| { type: "user_registered"; userId: string; email: string; timestamp: Date }
| {
type: "user_updated";
userId: string;
changes: Partial<{ email: string; name: string }>;
}
| { type: "user_deleted"; userId: string; deletedAt: Date }
| { type: "password_reset"; userId: string; token: string; expiresAt: Date };
function handleUserEvent(event: UserEvent): void {
switch (event.type) {
case "user_registered":
sendWelcomeEmail(event.email);
createUserRecord(event.userId, event.email);
break;
case "user_updated":
updateUserRecord(event.userId, event.changes);
break;
case "user_deleted":
softDeleteUser(event.userId, event.deletedAt);
break;
case "password_reset":
sendPasswordResetEmail(event.userId, event.token, event.expiresAt);
break;
default:
assertNever(event);
}
}Each event variant carries exactly the data it needs — nothing more, nothing less. The handler function is a single, exhaustive switch. Add a new event type to UserEvent and every handler that doesn't cover it will fail to compile.
The Discriminant Must Be a Literal Type
The discriminant property must have a literal type — "circle", 42, true
— not a broad type like string or number. TypeScript needs an exact value
to narrow on. If kind: string, TypeScript can't tell which variant you're in
when you check shape.kind === "circle", because any string member could
theoretically have that value.
Best Practices
Name your discriminant consistently. Across a codebase, stick to one or two conventions: type, kind, or status are common choices. Mixing conventions across different unions makes code harder to scan.
Put the discriminant first. It's conventional to declare the tag property first in each member type — it signals the pattern to readers immediately.
Prefer narrow variants over optional properties. Instead of one type with many optional fields, use a discriminated union with specific variants:
// Avoid — optional fields allow impossible combinations
type Notification = {
type: "email" | "sms" | "push";
email?: string; // only for "email" type
phoneNumber?: string; // only for "sms" type
deviceToken?: string; // only for "push" type
message: string;
};
// Prefer — each variant carries exactly what it needs
type Notification =
| { type: "email"; email: string; message: string }
| { type: "sms"; phoneNumber: string; message: string }
| { type: "push"; deviceToken: string; message: string };Always use assertNever in switch defaults. It costs one line and catches every missed case both at compile time and at runtime.
Key Takeaways
- A discriminated union is a union where every member shares a property with a unique literal type — the discriminant
- TypeScript narrows the full type on a simple equality check against the discriminant — no extra type guards needed
- The
assertNeverpattern in switch defaults gives you compile-time exhaustiveness checking: add a variant, TypeScript flags every handler that needs updating - Discriminated unions make impossible states unrepresentable — each variant carries only the data it needs
- Use them for async state, event systems, command handlers, and anywhere you switch on a "kind" or "type" field
Your State Modelling Just Got Precise
Discriminated unions are one of the patterns that separates idiomatic TypeScript from TypeScript-flavoured JavaScript. You now have the tools to model state that is impossible to misuse. Next: pattern matching with switch statements — deepening exhaustiveness, handling nested variants, and building utilities that work across any discriminated union.