Skip to content
Lesson 13 of 15

Step 1 of 7 · Reading · ~4 min

Learn

Type System Essentials

Union and Intersection Types

A union type says a value is ONE of several types; an intersection type says a value is ALL of several types at once. By the end of this lesson you can model a set of alternatives as a discriminated union, let the compiler narrow each case, and combine object types with an intersection.

Union types

function format(input: string | number): string {
    if (typeof input === 'string') return input.toUpperCase();
    return input.toFixed(2);
}

format('hello');    // 'HELLO'
format(3.14159);    // '3.14'

The parameter accepts either type. Inside the function you must narrow before using anything specific to one type: input.toUpperCase() on the whole union is a compile error, because a number has no such method. A union of string literals works like a small enum:

type Direction = 'north' | 'south' | 'east' | 'west';

function turn(d: Direction): void { /* ... */ }
turn('north');    // OK
turn('up');       // compile error: not a Direction

Intersection types

An intersection value must satisfy every member:

type Named = { name: string };
type Aged = { age: number };

type Person = Named & Aged;
const p: Person = { name: 'Ada', age: 36 };      // needs BOTH fields

type Employee = Person & { badge: string };       // build up from an existing type

Intersections are structural, with no inheritance order to worry about. The odd cases are the impossible ones. string & number is never, since no value is both, and { x: string } & { x: number } gives x the type never. Seeing never appear unexpectedly usually means two types you combined contradict each other.

Discriminated unions

The most useful pattern: a union of object types that each carry a discriminant, a shared field whose value is a different literal in each variant.

type Shape =
    | { kind: 'circle'; radius: number }
    | { kind: 'rectangle'; width: number; height: number }
    | { kind: 'triangle'; base: number; height: number };

function area(s: Shape): number {
    switch (s.kind) {
        case 'circle':    return Math.PI * s.radius ** 2;
        case 'rectangle': return s.width * s.height;
        case 'triangle':  return 0.5 * s.base * s.height;
    }
}

Inside each case, TypeScript knows exactly which variant s is, so s.radius exists only in the circle branch. Each variant carries only the fields that make sense for it, which is a closed, type-safe alternative to an inheritance hierarchy.

If you later add a fourth variant and forget a case, you can make the compiler tell you by giving the switch a default that proves nothing is left:

default: {
    const unreachable: never = s;   // compile error as soon as a variant is unhandled
    return unreachable;
}

Result types

A discriminant does not have to be a string; a boolean works for success-or-failure:

type Result<T> =
    | { ok: true; value: T }
    | { ok: false; error: string };

function parseAge(s: string): Result<number> {
    const n = parseInt(s, 10);
    if (Number.isNaN(n)) return { ok: false, error: 'not a number' };
    if (n < 0)           return { ok: false, error: 'negative' };
    return { ok: true, value: n };
}

const r = parseAge('42');
if (r.ok) {
    console.log(r.value);    // narrowed: value exists here
} else {
    console.log(r.error);    // narrowed: error exists here
}

A caller cannot read value without first checking ok, so forgetting the failure case becomes a compile error rather than a crash.

Common mistakes

  • Using a union member's method without narrowing. (input as string | number).toUpperCase() is an error until you narrow.
  • Giving variants no shared literal field. Without a discriminant the compiler cannot tell the cases apart.
  • Expecting an intersection to be an inheritance chain. A & B simply requires everything from both.
  • Trusting external data to match the union. Types are erased at runtime, so input must still be checked.

Your exercise

Process a list of bank commands with a discriminated union. The three kinds of command carry different data, and each gets an id for its position in the input by intersecting with a small extra type. The starter reads and parses the lines; you declare the types and write the function that carries out one command. The hidden tests include withdrawing exactly the whole balance.

Up nextType Aliases vs InterfacesType System Essentials

Discussion

Ask a question, share an insight, or help someone who’s stuck.

Sign in to post a comment or reply.

Loading…