Skip to main content

A value that's two things at once. A value that's one thing OR another. TypeScript does both, and that changes everything about how you design software.

Most programming languages force you to choose: either a value is a string or it's a number. Either a function returns a User or it returns null. Either a state is loading or it's success. But the real world doesn't work that way. API calls are loading AND THEN success AND THEN maybe error. User input is a string OR a number until you validate it. A shape is a circle OR a square OR a triangle.

TypeScript's union and intersection types let you model the real world. Not the simplified version. The messy, conditional, "it depends" version. And once you learn to think in unions, you'll wonder how you ever programmed without them.

Union Types: This OR That​

A union type represents a value that can be one of several types:

type Status = "idle" | "loading" | "success" | "error";

let currentStatus: Status = "idle";
currentStatus = "loading"; // OK
currentStatus = "done"; // Error: Type '"done"' is not assignable

The | operator reads as "or." string | number means "string or number." The value can be either, and TypeScript ensures you handle both possibilities.

Unions of Primitives​

function formatValue(value: string | number): string {
if (typeof value === "string") {
return value.toUpperCase();
}
return value.toFixed(2);
}

formatValue("hello"); // "HELLO"
formatValue(3.14159); // "3.14"
formatValue(true); // Error: Argument of type 'boolean' is not assignable

The function accepts string | number. Inside the function, you use typeof to narrow the type (more on narrowing in Chapter 8). Outside, callers can only pass string or number — anything else is an error.

Unions of Object Types​

This is where unions get powerful:

type SuccessResponse = {
status: "success";
data: User[];
};

type ErrorResponse = {
status: "error";
error: Error;
code: number;
};

type ApiResponse = SuccessResponse | ErrorResponse;

function handleResponse(response: ApiResponse): string {
if (response.status === "success") {
return `Got ${response.data.length} users`;
}
return `Error ${response.code}: ${response.error.message}`;
}

When you check response.status, TypeScript narrows the type. In the if branch, response is SuccessResponse and response.data is available. In the else branch, response is ErrorResponse and response.error is available.

This is a discriminated union — a union of object types that share a common property (the discriminant) with different literal values. The discriminant tells TypeScript exactly which variant you're dealing with.

The Discriminant Can Be Any Literal​

// Discriminant: 'kind'
type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; sideLength: number };
type Rectangle = { kind: "rectangle"; width: number; height: number };
type Shape = Circle | Square | Rectangle;

function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
case "rectangle":
return shape.width * shape.height;
}
}

// Discriminant: 'type'
type TextEvent = { type: "text"; content: string };
type ImageEvent = { type: "image"; url: string; alt: string };
type VideoEvent = { type: "video"; url: string; duration: number };
type Event = TextEvent | ImageEvent | VideoEvent;

The discriminant property can be named anything — status, kind, type, variant. The important thing is that each variant has a unique literal value for that property.

Intersection Types: This AND That​

While unions mean "OR," intersections mean "AND." An intersection type combines multiple types into one:

type HasName = { name: string };
type HasAge = { age: number };
type HasEmail = { email: string };

type User = HasName & HasAge & HasEmail;
// User = { name: string; age: number; email: string }

Intersections are most useful for composing types from smaller pieces:

type Timestamped = { createdAt: Date; updatedAt: Date };
type Identifiable = { id: number };
type SoftDeletable = { deletedAt: Date | null };

type User = { name: string; email: string } & Identifiable & Timestamped;
type Product = { name: string; price: number } & Identifiable & Timestamped & SoftDeletable;

This is the "mixin" pattern — you define reusable type fragments and compose them into concrete types.

Intersection vs. Extension​

These are equivalent:

// Using extends
interface User extends Identifiable, Timestamped {
name: string;
email: string;
}

// Using intersection
type User = { name: string; email: string } & Identifiable & Timestamped;

Use extends with interfaces for entity types. Use & with type aliases for ad-hoc compositions. The result is the same.

Conflicting Properties​

What happens when two intersected types have the same property with different types?

type A = { x: string };
type B = { x: number };
type C = A & B;
// C = { x: never }
// x must be both string AND number — impossible

The property type becomes never because no value can be both string and number simultaneously. This is usually a bug — if you're intersecting types with overlapping properties, make sure the types are compatible.

Discriminated Unions: The Pattern That Changes Everything​

Discriminated unions are the most important pattern in TypeScript. They're how you model state machines, API responses, UI states, and any situation where a value can be one of several distinct variants.

The Three Rules of Discriminated Unions​

  1. A common discriminant property with a literal type in each variant
  2. A union type combining all variants
  3. Type guards on the discriminant to narrow to specific variants

Real-World Example: UI State​

type UIState =
| { status: "idle" }
| { status: "loading"; progress: number }
| { status: "success"; data: string }
| { status: "error"; error: Error; retry: () => void };

function render(state: UIState): string {
switch (state.status) {
case "idle":
return "Waiting for input...";
case "loading":
return `Loading... ${state.progress}%`;
case "success":
return `Data: ${state.data}`;
case "error":
return `Error: ${state.error.message}. Retry?`;
}
}

Notice what's IMPOSSIBLE with this design:

  • You can't access state.data without first checking that status is "success"
  • You can't access state.progress without first checking that status is "loading"
  • You can't forget to handle a state — TypeScript will error if you miss a case

Real-World Example: Redux Actions​

type Action =
| { type: "ADD_TODO"; text: string }
| { type: "TOGGLE_TODO"; id: number }
| { type: "DELETE_TODO"; id: number }
| { type: "SET_FILTER"; filter: "all" | "active" | "completed" };

function reducer(state: TodoState, action: Action): TodoState {
switch (action.type) {
case "ADD_TODO":
return { ...state, todos: [...state.todos, { id: nextId(), text: action.text, done: false }] };
case "TOGGLE_TODO":
return { ...state, todos: state.todos.map(t => t.id === action.id ? { ...t, done: !t.done } : t) };
case "DELETE_TODO":
return { ...state, todos: state.todos.filter(t => t.id !== action.id) };
case "SET_FILTER":
return { ...state, filter: action.filter };
}
}

Each action type has its own payload shape. ADD_TODO has text. TOGGLE_TODO has id. TypeScript ensures you access the right payload for each action type.

Union Types and never​

never is the empty union. It represents a value that can never exist:

type Impossible = string & number; // never — nothing is both string and number
type Empty = never; // A type with no values

never is most useful for exhaustiveness checking. If you have a function that handles all variants of a union, TypeScript narrows the remaining type to never:

function assertNever(x: never): never {
throw new Error(`Unexpected value: ${x}`);
}

function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
case "rectangle":
return shape.width * shape.height;
default:
return assertNever(shape);
// If we add a new Shape variant, shape is no longer 'never'
// and TypeScript will error here — we forgot to handle it!
}
}

Union Distribution​

When you use a conditional type with a union, TypeScript distributes the condition over each member:

type ToArray<T> = T extends unknown ? T[] : never;

type Result = ToArray<string | number>;
// = ToArray<string> | ToArray<number>
// = string[] | number[]

This is called distributive conditional types. It's the foundation of many advanced type utilities. We'll explore it fully in Chapter 14.

When to Use Unions vs. Enums​

TypeScript has enum as an alternative to union types:

// Union type (preferred)
type Status = "idle" | "loading" | "success" | "error";

// Enum (use sparingly)
enum StatusEnum {
Idle = "idle",
Loading = "loading",
Success = "success",
Error = "error",
}

My recommendation: use union types of string literals instead of enums. Unions are simpler, don't generate runtime code, and work better with TypeScript's type inference. The only reason to use enums is if you need the runtime value (e.g., iterating over all variants).

Try This: Unions and Discriminated Unions​

  1. Create a type PaymentMethod = "credit_card" | "debit_card" | "paypal" | "crypto". Write a function that takes a PaymentMethod and returns a human-readable label.
  2. Create a discriminated union for a shopping cart:
    • { status: "empty" }
    • { status: "active"; items: CartItem[]; total: number }
    • { status: "checking_out"; items: CartItem[]; total: number; paymentMethod: PaymentMethod }
    • { status: "completed"; orderId: string }
  3. Write a renderCart function that handles all four states. Use assertNever in the default case.
  4. Create a type Result<T> = { success: true; value: T } | { success: false; error: Error }. Write a function that uses it.
  5. Intentionally add a new variant to one of your unions. See where TypeScript errors — those are the places you need to update.

Time needed: 20 minutes.

What to notice: How discriminated unions make it impossible to access data in the wrong state. How assertNever gives you compile-time guarantees that you've handled every case. How the type system becomes a state machine verifier.

The Question​

You now know how to define types that represent "this OR that." But knowing what type something COULD be is only half the battle. The other half is figuring out what type something ACTUALLY is — at runtime, in a specific code path, when you need to make a decision.

That's called narrowing. And it's the subject of the next chapter.


In the next chapter: type guards, type predicates, assertion functions, and the art of helping TypeScript understand exactly what type a value is at any given point in your code.