On this page
Union and intersection types
Union types
A union type describes a value that can be one of several types. You create a union with the | operator:
type StringOrNumber = string | number;
function formatValue(value: StringOrNumber): string {
if (typeof value === "string") {
return value.toUpperCase();
}
return value.toFixed(2);
}
formatValue("hello"); // "HELLO"
formatValue(9.99); // "9.99"
formatValue(true); // Error: Argument of type 'boolean' is not assignable to parameter of type 'StringOrNumber'Union types are ubiquitous in TypeScript. Every optional property (name?: string) is implicitly a union with undefined (name: string | undefined). null-able values are unions too (string | null).
Literal types
A literal type is a type whose only inhabitant is one specific value. Strings, numbers, and booleans all have literal forms:
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
type StatusCode = 200 | 201 | 400 | 401 | 404 | 500;
type Enabled = true; // a type that can only be true
function request(method: HttpMethod, url: string): void {
// method can only be one of the five HTTP verbs
fetch(url, { method });
}Literal types are most powerful when combined into unions. A union of string literals is equivalent to an enum but simpler, produces no JavaScript output, and works naturally with JSON serialization.
Template literal types
TypeScript 4.1 introduced template literal types — literal types constructed from string interpolation patterns:
type EventName = "click" | "focus" | "blur";
type Handler = `on${Capitalize<EventName>}`;
// Equivalent to: "onClick" | "onFocus" | "onBlur"
type CSSProperty = "padding" | "margin";
type CSSDirection = "Top" | "Right" | "Bottom" | "Left";
type CSSPropertyWithDirection = `${CSSProperty}${CSSDirection}`;
// "paddingTop" | "paddingRight" | ... | "marginBottom" | "marginLeft"These are computed at the type level — zero runtime cost.
Intersection types
An intersection type requires a value to satisfy all intersected types simultaneously. You create intersections with the & operator:
type Named = { name: string };
type Aged = { age: number };
type Person = Named & Aged;
const alice: Person = {
name: "Alice",
age: 30,
}; // must have both name and ageIntersections are additive: the resulting type has all properties from all members. They are the type alias equivalent of interface extends.
A common pattern is using intersections to add cross-cutting concerns like timestamps, soft-delete flags, or audit information:
type WithId = { id: string };
type WithTimestamps = { createdAt: Date; updatedAt: Date };
type WithSoftDelete = { deletedAt: Date | null };
type BaseEntity = WithId & WithTimestamps & WithSoftDelete;
type User = BaseEntity & {
email: string;
name: string;
};
type Order = BaseEntity & {
userId: string;
total: number;
items: string[];
};Discriminated unions
A discriminated union (also called a tagged union) is a union of object types where each member has a unique literal property — the discriminant. TypeScript uses this discriminant to narrow the union to a specific member inside conditional branches.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
// TypeScript knows shape is { kind: "circle"; radius: number }
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return (shape.base * shape.height) / 2;
}
}Without the discriminant, TypeScript cannot determine which union member is active in each case, and you would need explicit type guards or assertions everywhere.
Real-world discriminated union: API responses
Discriminated unions model asynchronous state beautifully:
type ApiResponse<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string; retryable: boolean };
function handleUserResponse(response: ApiResponse<User[]>): string {
switch (response.status) {
case "idle":
return "Not started";
case "loading":
return "Fetching users…";
case "success":
return `Loaded ${response.data.length} users`;
case "error":
return `Failed: ${response.error}`;
}
}This pattern eliminates impossible state combinations. You can never accidentally access response.data when response.status is "error" — TypeScript prevents it.
Exhaustive checking with never
When using a discriminated union in a switch statement, you can add a default branch that assigns the remaining value to never. If every case is handled, the default branch is unreachable. If you add a new union member and forget to handle it, TypeScript reports an error:
type Color = "red" | "green" | "blue";
function describeColor(color: Color): string {
switch (color) {
case "red": return "warm";
case "green": return "cool";
case "blue": return "cool";
default: {
const exhaustive: never = color; // ERROR if Color has unhandled members
throw new Error(`Unhandled color: ${exhaustive}`);
}
}
}Now add "yellow" to Color:
type Color = "red" | "green" | "blue" | "yellow";
// TypeScript immediately reports:
// Type '"yellow"' is not assignable to type 'never'This technique turns missed cases into compile errors rather than silent runtime bugs.
Narrowing union types
TypeScript narrows union types automatically through several mechanisms:
type StringOrNumber = string | number;
function process(value: StringOrNumber): string {
// typeof narrowing
if (typeof value === "string") {
return value.trim(); // value: string here
}
return String(value); // value: number here
}
// instanceof narrowing
function handleError(err: Error | string): void {
if (err instanceof Error) {
console.error(err.message); // err: Error here
} else {
console.error(err); // err: string here
}
}
// in operator narrowing
type Cat = { meow(): void };
type Dog = { bark(): void };
function makeNoise(animal: Cat | Dog): void {
if ("meow" in animal) {
animal.meow(); // animal: Cat here
} else {
animal.bark(); // animal: Dog here
}
}TypeScript performs control flow analysis — it tracks which narrowing conditions are active at every point in the code and adjusts the inferred type accordingly.
Practice
- Model a payment system with a discriminated union:
CreditCardPayment,BankTransferPayment, andCryptocurrencyPayment. Each should have amethoddiscriminant and its own unique fields. Write a function that processes any payment and returns a formatted receipt string. - Create a template literal type that generates all valid CSS
border-{side}property names (border-top,border-right,border-bottom,border-left). Verify that an invalid property name is a compile error. - Implement an exhaustive switch with a
nevercheck. Add a new union member and confirm TypeScript reports the error immediately.
// Each variant has a unique literal "kind" field — the discriminant
type LoadingState = {
kind: "loading";
};
type SuccessState = {
kind: "success";
data: string[];
};
type ErrorState = {
kind: "error";
message: string;
code: number;
};
// The union of all three variants
type FetchState = LoadingState | SuccessState | ErrorState;
function renderState(state: FetchState): string {
switch (state.kind) {
case "loading":
return "Loading…";
case "success":
// TypeScript knows state is SuccessState here
return state.data.join(", ");
case "error":
// TypeScript knows state is ErrorState here
return `Error ${state.code}: ${state.message}`;
default: {
// Exhaustive check — compile error if a new variant is missed
const exhaustive: never = state;
return exhaustive;
}
}
}
Sign in to track your progress