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 & Bsimply 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.
Discussion
Ask a question, share an insight, or help someone who’s stuck.
Sign in to post a comment or reply.
Loading…