TypeScript Union & Intersection Types Cheat Sheet
Unions, intersections, discriminated unions, literal types, and exhaustive checking patterns -- with copy-ready examples for every scenario.
Basic Union (A | B)
A union type accepts any one of the listed types. The pipe `|` means "or".
function formatId(id: string | number): string {
return `ID-${id}`;
}
formatId(123); // => 'ID-123'
formatId('abc'); // => 'ID-abc'
// formatId(true); // => Error: boolean not assignable to string | numberlet result: string | null = null;
result = 'success'; // OK
result = null; // OK
// result = 42; // Error: number not assignable to string | nullfunction getLength(value: string | number[]): number {
// .length exists on both string and number[]
return value.length;
}
getLength('hello'); // => 5
getLength([1, 2, 3]); // => 3You can only access properties that exist on every member of the union without narrowing first.
Literal Unions
String and numeric literal types restrict a value to exact matches. Much stricter than plain `string` or `number`.
type Direction = 'north' | 'south' | 'east' | 'west';
function move(dir: Direction): void {
console.log(`Moving ${dir}`);
}
move('north'); // OK
// move('up'); // Error: 'up' not assignable to Directiontype DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;
function roll(): DiceRoll {
return (Math.floor(Math.random() * 6) + 1) as DiceRoll;
}// boolean is actually: true | false
type Bool = true | false;
// This means you can narrow booleans the same way:
type IsAdmin = true;
type IsGuest = false;TypeScript treats `boolean` as an alias for `true | false` internally. Knowing this helps explain why boolean works with discriminated unions.
Discriminated Unions
A shared literal property (the discriminant) lets TypeScript narrow the union to one specific member in each branch.
type Circle = { kind: 'circle'; radius: number };
type Rectangle = { kind: 'rectangle'; width: number; height: number };
type Shape = Circle | Rectangle;
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rectangle':
return shape.width * shape.height;
}
}type ApiResult<T> =
| { status: 'success'; data: T }
| { status: 'error'; message: string }
| { status: 'loading' };
function handleResult(result: ApiResult<string[]>) {
if (result.status === 'success') {
console.log(result.data); // TS knows data exists here
} else if (result.status === 'error') {
console.log(result.message); // TS knows message exists here
}
}type Action =
| { type: 'INCREMENT'; amount: number }
| { type: 'DECREMENT'; amount: number }
| { type: 'RESET' };
function reducer(state: number, action: Action): number {
switch (action.type) {
case 'INCREMENT': return state + action.amount;
case 'DECREMENT': return state - action.amount;
case 'RESET': return 0;
}
}The `type` property is the discriminant. Each branch narrows `action` to the matching member.
Intersection Types (A & B)
An intersection combines multiple types into one. The result must satisfy all constituent types simultaneously.
type HasId = { id: string };
type HasTimestamps = { createdAt: Date; updatedAt: Date };
type Entity = HasId & HasTimestamps;
// Must have all properties from both types
const post: Entity = {
id: 'abc',
createdAt: new Date(),
updatedAt: new Date(),
};type Loggable = { log(): void };
type Serializable = { toJSON(): string };
function process(item: Loggable & Serializable) {
item.log();
const json = item.toJSON();
}type Impossible = string & number; // => never
// This makes sense: no value is both a string and a number
// let x: Impossible = ??? // nothing worksIntersecting incompatible types produces `never`. This trips people up when intersecting object types with conflicting properties -- the conflicting property becomes `never`, not the whole type.
Union with null/undefined (Nullable)
With `strictNullChecks` on, you must explicitly include `null` or `undefined` in the type to allow those values.
// Optional property -- can be missing entirely
interface UserOptional {
name: string;
nickname?: string; // string | undefined
}
// Nullable property -- must be present, but can be null
interface UserNullable {
name: string;
nickname: string | null;
}
const a: UserOptional = { name: 'Alice' }; // OK: nickname missing
const b: UserNullable = { name: 'Alice', nickname: null }; // OK: nickname is null
// const c: UserNullable = { name: 'Alice' }; // Error: nickname missingfunction greet(name: string | null): string {
if (name === null) {
return 'Hello, stranger';
}
// TypeScript knows name is string here
return `Hello, ${name.toUpperCase()}`;
}function getElement(id: string): HTMLElement {
const el = document.getElementById(id);
// el is HTMLElement | null
return el!; // Assert non-null (throws at runtime if null)
}The `!` operator silences the null check. Use it sparingly -- it defeats the purpose of strictNullChecks. Prefer narrowing with an if-check or throwing an explicit error.
Narrowing Unions
TypeScript tracks control flow and narrows the union to a specific member after type checks.
function double(value: string | number): string | number {
if (typeof value === 'string') {
return value.repeat(2); // string branch
}
return value * 2; // number branch
}type Fish = { swim(): void };
type Bird = { fly(): void };
function move(animal: Fish | Bird) {
if ('swim' in animal) {
animal.swim(); // Fish
} else {
animal.fly(); // Bird
}
}function formatError(err: Error | string): string {
if (err instanceof Error) {
return `${err.name}: ${err.message}`;
}
return err; // string
}type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
function isReadOnly(method: HTTPMethod): boolean {
if (method === 'GET') {
return true; // method is narrowed to 'GET'
}
return false; // method is 'POST' | 'PUT' | 'DELETE'
}See the Type Narrowing cheat sheet for the full set of narrowing techniques including type guards and assertion functions.
Exhaustive Switch Pattern
Assigning the unmatched value to `never` in a default case forces a compile error if you forget a union member.
type Status = 'pending' | 'active' | 'archived';
function statusLabel(status: Status): string {
switch (status) {
case 'pending': return 'Pending';
case 'active': return 'Active';
case 'archived': return 'Archived';
default: {
const _exhaustive: never = status;
return _exhaustive; // never reached if all cases handled
}
}
}
// If you add 'deleted' to Status and forget to handle it:
// => Error: Type 'deleted' not assignable to type 'never'function assertNever(value: never, msg?: string): never {
throw new Error(msg ?? `Unexpected value: ${value}`);
}
type Shape = { kind: 'circle' } | { kind: 'square' };
function draw(shape: Shape) {
switch (shape.kind) {
case 'circle': /* ... */ break;
case 'square': /* ... */ break;
default: assertNever(shape);
}
}The assertNever helper doubles as a runtime guard -- if a value somehow reaches it (e.g., from unvalidated JSON), the throw provides a clear error instead of silent failure.
Union vs Enum
String literal unions and enums solve the same problem. Here is when to pick each.
type Color = 'red' | 'green' | 'blue';
// No runtime cost -- erased during compilation
// Autocomplete works in editors
// Easy to extend: type ExtColor = Color | 'yellow';
function paint(color: Color) { /* ... */ }
paint('red');enum Color {
Red = 'RED',
Green = 'GREEN',
Blue = 'BLUE',
}
// Exists at runtime -- you can iterate over it
Object.values(Color); // => ['RED', 'GREEN', 'BLUE']
function paint(color: Color) { /* ... */ }
paint(Color.Red);const enum Direction {
Up = 'UP',
Down = 'DOWN',
Left = 'LEFT',
Right = 'RIGHT',
}
// Inlined at compile time -- no runtime object
const dir = Direction.Up; // compiles to: const dir = 'UP'const enums are inlined during compilation so they have zero runtime cost. But they break with `--isolatedModules` (used by Babel, esbuild, SWC) and don't support `Object.values()`. Prefer string literal unions unless you have a specific reason for enum.
Common Union Patterns
Real-world union and intersection patterns you'll use in production TypeScript.
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function divide(a: number, b: number): Result<number, string> {
if (b === 0) return { ok: false, error: 'Division by zero' };
return { ok: true, value: a / b };
}
const result = divide(10, 3);
if (result.ok) {
console.log(result.value); // number -- narrowed by discriminant
} else {
console.log(result.error); // string
}type AppEvents = {
login: { userId: string; timestamp: number };
logout: { userId: string };
error: { code: number; message: string };
};
type EventName = keyof AppEvents;
function emit<K extends EventName>(
event: K,
payload: AppEvents[K]
): void {
// type-safe event emission
}
emit('login', { userId: '123', timestamp: Date.now() });
// emit('login', { wrong: true }); // Errortype USD = number & { readonly __brand: 'USD' };
type EUR = number & { readonly __brand: 'EUR' };
function usd(amount: number): USD { return amount as USD; }
function eur(amount: number): EUR { return amount as EUR; }
function addUSD(a: USD, b: USD): USD {
return (a + b) as USD;
}
const price = usd(10);
const tax = usd(2);
addUSD(price, tax); // OK
// addUSD(price, eur(5)); // Error: EUR not assignable to USDBranded types use an intersection with a phantom property to prevent accidental mixing of semantically different values that share the same underlying type.
Can you write this from memory?
Declare `id` that can be string or number