Lesson 10 of 20

Union & Intersection Types

A Union Says "One of These"

A union type, written with the pipe character, describes a value that is one of several types: string | number holds either a string or a number, never both. Unions are how you describe reality — an id that some APIs send as a number and others as a string, a form field that is empty until filled, a value that might be null.

The important rule follows from the definition. While a value's type is a union, you may only use the members that every option in the union has. TypeScript does not know which one you are holding, so it will not let you call toUpperCase on something that might be a number. This feels restrictive for about a day, and then you notice it is stopping the exact crash you used to write.

The most valuable unions are not mixed primitives, though. They are unions of string literals, which turn a loose string parameter into a fixed menu of valid values, and unions with null or undefined, which force you to handle the missing case. Between them those two patterns account for most of the unions in a real codebase.

Example
type ID = string | number;

function show(id: ID) {
  // id.toUpperCase();
  // ERROR: Property 'toUpperCase' does not exist on type 'number'.

  console.log(id.toString());   // OK — both string and number have toString
}

// A union of literals: a fixed menu
type PaymentMode = "upi" | "card" | "netbanking" | "cod";

function charge(mode: PaymentMode, amount: number) { /* ... */ }

charge("upi", 499);    // OK, and autocompleted
charge("paytm", 499);  // ERROR: not assignable to type 'PaymentMode'

// A union with null: the missing case is now visible in the type
function findStudent(id: number): Student | null {
  return students.find(s => s.id === id) ?? null;
}
Notes
  • A union is not an "or else" fallback. string | number does not mean "a string, or convert it to a number" — it means the value genuinely is one of the two, and you are responsible for finding out which.

Narrowing: How You Actually Use a Union

To use the members of one particular option, you first have to convince the compiler that is what you are holding. The process is called narrowing, and the pleasant surprise is that you do it with ordinary JavaScript checks you already write — typeof, a comparison, a truthiness test, Array.isArray, instanceof.

TypeScript follows the flow of your code. Inside an if (typeof value === "string") block, the type of value is string, not string | number. In the else branch it is number, because the only other option has been eliminated. An early return narrows everything after it in the same way, which is why guard clauses read so well in TypeScript.

Narrowing is not permanent, and this is the part that catches people. It applies to the block the check controls. Reassign the variable and the narrowing is discarded; read it inside a callback that runs later and TypeScript assumes it may have changed in the meantime. The reliable fix in both cases is to copy the narrowed value into a const and use that.

Example
function format(value: string | number): string {
  if (typeof value === "string") {
    return value.trim().toUpperCase();   // value is string here
  }
  return value.toFixed(2);               // value is number here
}

// Guard clauses narrow everything below them
function shout(name: string | null): string {
  if (name === null) return "HELLO";
  return name.toUpperCase();             // name is string from here on
}

// Narrowing does not survive into a later callback
interface Form { note: string | null }

function handle(form: Form) {
  if (form.note !== null) {
    // setTimeout(() => console.log(form.note.length), 0);
    // ERROR: 'form.note' is possibly 'null'. It could change before this runs.

    const note = form.note;              // copy it out
    setTimeout(() => console.log(note.length), 0);   // fine
  }
}

Discriminated Unions: the Pattern to Learn Today

A discriminated union is a union of object types that all share one property holding a different literal value — kind, type, status, whatever you like. Checking that one property tells TypeScript exactly which member you have, and it narrows the whole object accordingly, including the properties that only exist on that member.

The reason this matters is bigger than convenience. Consider the state of a screen that loads data: most people model it as one object with isLoading, data and error, all optional. That type permits states that cannot happen — loading and errored at once, finished with neither data nor error — and every component then defends against them with nested checks. A discriminated union makes those states unrepresentable. There is no way to construct an invalid one, so there is nothing to defend against.

This is the single highest-value pattern in everyday TypeScript, and it is worth reaching for whenever a thing can be in one of several states: a request, a form field, a payment, a WebSocket connection, the result of a parse. Once the union is in place, the code that consumes it usually collapses into one clean switch.

Example
// Every member carries a different literal in the same property
type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: Student[] }
  | { status: "error"; message: string };

function render(state: RequestState): string {
  switch (state.status) {
    case "idle":
      return "Nothing requested yet";
    case "loading":
      return "Loading...";
    case "success":
      return `${state.data.length} students`;   // data exists only here
    case "error":
      return `Failed: ${state.message}`;        // message exists only here
  }
}

// Compare with the shape most people write first:
//   { isLoading: boolean; data?: Student[]; error?: string }
// That type allows isLoading: true with an error set, and success
// with no data at all. Both are states your UI must then handle.
Notes
  • The discriminant must be a literal type — "loading", not string — and it must be present on every member. If narrowing is not working, that is nearly always the reason.

Exhaustiveness: Making the Compiler Find Every Case

Discriminated unions unlock a trick that is worth the price of admission on its own. Inside a switch, once every known case has been handled, the value's type in the default branch is never — there is nothing left it could be. Assigning it to a variable annotated never therefore compiles cleanly.

Now add a new member to the union. That default branch is suddenly reachable with a real type, the assignment to never fails, and the compiler tells you precisely which file and which function has not been updated. Repeat until the errors stop and you have found every place in the codebase that needed to know about the new case.

This turns a change that would normally require careful grepping into a mechanical task. It is why large TypeScript codebases can add a new order status or a new payment mode with confidence. Write the exhaustiveness check into every switch over a union you own — it costs three lines and pays for itself the first time the union grows.

Example
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.side ** 2;
    default: {
      const exhaustive: never = shape;   // compiles today
      throw new Error(`Unhandled shape: ${JSON.stringify(exhaustive)}`);
    }
  }
}

// Someone adds a third member:
//   | { kind: "rectangle"; width: number; height: number }
//
// and this error appears, pointing at the exact function to fix:
// ERROR: Type '{ kind: "rectangle"; ... }' is not assignable to type 'never'.
Notes
  • The same technique works with if/else if chains — put the never assignment in the final else. A switch is usually clearer, but the trick is not tied to it.

Intersections: "All of These at Once"

Where a union is "one of", an intersection written with & is "all of". A & B describes a value that has everything A requires and everything B requires. The symbols read backwards to most people at first: the one that looks like "and" produces fewer valid values, and the one that looks like "or" produces more.

Intersections are for composing independent pieces of a shape. A set of timestamp fields that every stored record carries; a base set of props shared by every button in a design system; an API envelope wrapped around a payload. Each piece is defined once and combined where needed, which is much easier to maintain than a dozen near-identical interfaces.

Two cautions. First, as the previous lesson showed, intersecting two types that declare the same property with different types silently produces never for that property rather than an error at the declaration — so prefer interface extends when both sides are shapes you own. Second, an intersection of unrelated primitives is always never, because no value can be a string and a number simultaneously.

Example
type Timestamps = { createdAt: Date; updatedAt: Date };
type Identified = { id: number };

type Student = Identified & Timestamps & {
  name: string;
  marks: number;
};

const s: Student = {
  id: 1,
  name: "Ananya",
  marks: 88,
  createdAt: new Date(),
  updatedAt: new Date()
};

// A reusable API envelope
type Envelope<T> = { status: number; requestId: string } & T;

type StudentResponse = Envelope<{ student: Student }>;

// The empty intersection
type Impossible = string & number;   // never — nothing can be both
Notes
  • A quick way to keep them straight: a union widens the set of values but narrows what you may do with them; an intersection narrows the set of values but widens what you may do.

Choosing Between a Union and Optional Properties

The design decision you will face repeatedly is whether to model variation as a union of shapes or as one shape with optional fields. Optional fields are quicker to write and are the right answer when the fields really are independent — a profile where the phone number, the photo and the bio may each be present or absent in any combination.

A union is the right answer when the fields travel together: when having data means you cannot have error, or when paymentId only exists once the status is paid. Optional fields cannot express that relationship, so the invariant lives in your head and in comments, and eventually somebody constructs an object that breaks it.

A good test: count the states your type permits, then count the states that are actually possible. Four optional booleans permit sixteen combinations. If your feature only has three real states, a three-member discriminated union is both smaller and safer.

  • Use optional properties when fields vary independently and any combination is valid
  • Use a discriminated union when the presence of one field implies the presence or absence of others
  • Keep the discriminant name consistent across your codebase — kind, type or status, but pick one
  • Give the discriminant a literal type; a plain string will not narrow anything
  • Add an exhaustiveness check to every switch over a union you control
  • If a type has more optional properties than required ones, that is a strong hint it should be a union
Ask AI