Union, Intersection & Literal Types: Discriminated Unions in Practice
Union Types: How A | B Actually Works
A union type says "this value is either A or B." The most direct use case is a function that accepts multiple input forms:
// Accept either a string slug or a numeric ID
function findUser(id: string | number) {
if (typeof id === "string") {
return db.findBySlug(id); // id is string here
}
return db.findById(id); // id is number here
}
Union members don't have to be primitives — objects work too:
type StringOrArray = string | string[];
function normalize(input: StringOrArray): string[] {
if (typeof input === "string") {
return [input];
}
return input; // TypeScript knows this branch is string[]
}
Literal Types: More Precise Than string
A literal type is the type of a specific value. "GET" is not just any string — it is a type whose only inhabitant is the string "GET".
// HTTP methods are a closed set — no reason to accept arbitrary strings
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
function request(url: string, method: HttpMethod) {
return fetch(url, { method });
}
request("/api/users", "GET"); // OK
request("/api/users", "FETCH"); // Error: not a valid method
// Numeric literals work the same way
type DiceValue = 1 | 2 | 3 | 4 | 5 | 6;
function rollDice(): DiceValue {
return (Math.floor(Math.random() * 6) + 1) as DiceValue;
}
Use as const to infer literal types from object values:
const CONFIG = {
env: "production",
port: 3000,
} as const;
// CONFIG.env is "production", not string
// CONFIG.port is 3000, not number
type Env = typeof CONFIG.env; // "production"
Type Narrowing: Helping TypeScript Pick the Right Branch
When you have a union type, you need to narrow it before accessing members specific to one variant. TypeScript recognises four built-in narrowing forms.
1. typeof — for primitives
function format(value: string | number | boolean): string {
if (typeof value === "string") {
return value.toUpperCase(); // string methods available
}
if (typeof value === "number") {
return value.toFixed(2); // number methods available
}
return value ? "YES" : "NO"; // boolean
}
2. instanceof — for class instances
class ApiError extends Error {
constructor(public statusCode: number, message: string) {
super(message);
}
}
function handleError(err: Error | ApiError) {
if (err instanceof ApiError) {
console.log(`HTTP ${err.statusCode}: ${err.message}`);
} else {
console.log(`Unknown error: ${err.message}`);
}
}
3. in operator — for property presence
interface Dog { bark(): void; breed: string; }
interface Cat { meow(): void; indoor: boolean; }
function makeSound(animal: Dog | Cat) {
if ("bark" in animal) {
animal.bark(); // Dog
} else {
animal.meow(); // Cat
}
}
4. Custom type guards — is keyword
interface AdminUser { role: "admin"; permissions: string[]; }
interface GuestUser { role: "guest"; }
type User = AdminUser | GuestUser;
// The return type `user is AdminUser` tells TypeScript:
// when this function returns true, treat the argument as AdminUser
function isAdmin(user: User): user is AdminUser {
return user.role === "admin";
}
function getPermissions(user: User): string[] {
if (isAdmin(user)) {
return user.permissions; // TypeScript confirms AdminUser here
}
return [];
}
Discriminated Unions: Modelling State Machines
A discriminated union is a union where every member carries a shared field with a distinct literal value — called the discriminant. TypeScript uses this field to narrow the type exactly.
Real Example 1: API Response States
Without discriminated unions, state is represented with optional fields, and illegal states are representable:
// Anti-pattern: optional fields to represent state — which fields are active?
interface ApiState {
loading: boolean;
data?: string[];
error?: Error;
}
function render(state: ApiState) {
// Can data and error both be set at once? TypeScript won't tell you.
if (state.data && state.error) { /* ambiguous */ }
}
Rewrite with a discriminated union — every state is exclusive and complete:
interface LoadingState {
kind: "loading";
}
interface SuccessState {
kind: "success";
data: string[];
timestamp: number;
}
interface ErrorState {
kind: "error";
error: Error;
retryCount: number;
}
type ApiState = LoadingState | SuccessState | ErrorState;
function render(state: ApiState): string {
switch (state.kind) {
case "loading":
return "Loading...";
case "success":
// TypeScript confirms state.data is available
return state.data.join(", ");
case "error":
// TypeScript confirms state.error and state.retryCount are available
return `Error: ${state.error.message} (retry ${state.retryCount})`;
}
}
const state: ApiState = { kind: "success", data: ["a", "b"], timestamp: Date.now() };
console.log(render(state)); // "a, b"
Real Example 2: Shape Types with Exhaustiveness Checking
interface Circle {
kind: "circle";
radius: number;
}
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
interface Triangle {
kind: "triangle";
base: number;
height: number;
}
type Shape = Circle | Rectangle | Triangle;
// Exhaustiveness helper: if a case is missed, TypeScript errors here
// because Shape is not assignable to never
function assertNever(x: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}
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);
// If you add a new Shape variant but forget a case here,
// TypeScript will error: "Triangle is not assignable to never"
}
}
Modelling a State Machine
type TrafficLight =
| { state: "red"; nextState: "green" }
| { state: "green"; nextState: "yellow" }
| { state: "yellow"; nextState: "red" };
function next(light: TrafficLight): TrafficLight {
switch (light.state) {
case "red": return { state: "green", nextState: "yellow" };
case "green": return { state: "yellow", nextState: "red" };
case "yellow": return { state: "red", nextState: "green" };
}
}
Intersection Types: A & B
An intersection type means "satisfies both A and B simultaneously." The result has all properties of both.
Use Case 1: Extending Third-Party Types
// Imagine this comes from a library you cannot modify
interface ThirdPartyUser {
id: number;
name: string;
}
// Extend with intersection instead of touching the original
type AppUser = ThirdPartyUser & {
role: "admin" | "user";
createdAt: Date;
};
const user: AppUser = {
id: 1,
name: "Alice",
role: "admin",
createdAt: new Date(),
};
Use Case 2: Mixin Pattern
interface Timestamped {
createdAt: Date;
updatedAt: Date;
}
interface SoftDeletable {
deletedAt: Date | null;
isDeleted: boolean;
}
type AuditableEntity = Timestamped & SoftDeletable & {
createdBy: string;
};
type Post = AuditableEntity & {
title: string;
content: string;
};
const post: Post = {
title: "Hello",
content: "World",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
isDeleted: false,
createdBy: "alice",
};
Intersection Pitfalls
// Primitive intersections collapse to never
type Impossible = string & number; // never
// Distribution over unions
type A = (string | number) & string; // string — only string satisfies both
Anti-Pattern: Optional Fields Instead of Discriminated Unions
// Anti-pattern: optional fields allow illegal states
interface PaymentState {
isPending?: boolean;
successAmount?: number;
failureReason?: string;
}
// Nothing prevents isPending=true AND successAmount=100 simultaneously.
// TypeScript can't help you catch this contradiction.
// Correct: discriminated union makes illegal states unrepresentable
type Payment =
| { status: "pending" }
| { status: "success"; amount: number; transactionId: string }
| { status: "failed"; reason: string; retryable: boolean };
// In "pending" state, `amount` simply does not exist — enforced by the type system.
Key Takeaways
| Feature | Syntax | Best Used For |
|---|---|---|
| Union type | A | B |
Value can be one of several types |
| Literal type | "get" | "post" |
Restrict to a known set of values |
typeof narrowing |
typeof x === "string" |
Distinguishing primitives |
instanceof narrowing |
x instanceof MyClass |
Distinguishing class instances |
in narrowing |
"field" in x |
Distinguishing interface/object shapes |
| Custom type guard | (x): x is T |
Complex narrowing logic |
| Discriminated union | Shared kind field |
Mutually exclusive states, exhaustiveness |
| Intersection type | A & B |
Combining types, Mixin pattern |
Next chapter: the key differences between interface and type alias, and a decision tree to help you choose between them.