Unions and Literal Types
The single most useful thing in the language. "One of these exact values" turns a whole class of runtime bug into a compile error.
By the end: Model a value that can be one of several things, precisely.
If you learn one thing from this course, make it this lesson.
A union type says “one of these”. That sounds modest. In practice it is the feature that lets you describe the messy realities JavaScript code is full of — a value that might be missing, a response that either succeeded or failed, a setting that is one of four specific strings — and turn a whole category of runtime bug into a compile error.
Most of the type-level cleverness people show off with is unnecessary. Unions are not. They are the everyday tool.
One of these
// A vertical bar between types means "one of these".
type Id = string | number;
function show(id: Id): string {
return `id: ${String(id)}`;
}
console.log(show('a-1'));
console.log(show(42));
// Unions can have as many members as you like.
type Primitive = string | number | boolean | null;
function describe(value: Primitive): string {
return `${typeof value}: ${String(value)}`;
}
console.log(describe('text'));
console.log(describe(1));
console.log(describe(true));
console.log(describe(null));
Output
The compiler will only let you do things that are valid for every member of the union. That restriction is the whole safety mechanism, and it is worth seeing fail once.
type Id = string | number;
function shout(id: Id): string {
// toUpperCase exists on string but not on number, so it is not available
// on the union. You have to establish which one you have first.
return id.toUpperCase();
}
console.log(shout('a-1'));
What the compiler says
error TS2339: Property 'toUpperCase' does not exist on type 'Id'.
Literal types, the other half
A literal type is a type with exactly one value in it. 'north' is a type, and the only thing assignable to it is the string 'north'. On their own literal types look pointless. Combined with unions they are the most useful thing in the language.
type Direction = 'north' | 'south' | 'east' | 'west';
function move(steps: number, heading: Direction): string {
return `${steps} paces ${heading}`;
}
console.log(move(3, 'north'));
console.log(move(1, 'west'));
// The same for numbers and booleans.
type Dice = 1 | 2 | 3 | 4 | 5 | 6;
type Strict = true;
function roll(value: Dice): string {
return `rolled ${value}`;
}
console.log(roll(6));
const mustBeTrue: Strict = true;
console.log(mustBeTrue);
Output
type Direction = 'north' | 'south' | 'east' | 'west';
function move(steps: number, heading: Direction): string {
return `${steps} paces ${heading}`;
}
// In plain JavaScript this would have shipped and produced nonsense at some
// point in the future, probably in production, probably on a Friday.
console.log(move(3, 'noth'));
What the compiler says
error TS2345: Argument of type '"noth"' is not assignable to parameter of type 'Direction'.
Discriminated unions
Now the pattern that does the heavy lifting in real applications, and the reason unions matter so much more than they first appear.
Give each member of a union a shared property whose literal type differs. That property is a tag the compiler can read, and once it has read it, it knows precisely which member you are holding and gives you access to everything on it.
// Each member has a `kind`, and each `kind` is a different literal type.
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number };
function area(shape: Shape): number {
// Checking `kind` tells the compiler which member this is. Inside each
// branch, the properties of that member are available and the others are not.
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rectangle':
return shape.width * shape.height;
case 'triangle':
return (shape.base * shape.height) / 2;
}
}
const shapes: Shape[] = [
{ kind: 'circle', radius: 1 },
{ kind: 'rectangle', width: 3, height: 4 },
{ kind: 'triangle', base: 6, height: 2 },
];
for (const shape of shapes) {
console.log(shape.kind.padEnd(10), area(shape).toFixed(2));
}
Output
Try to reach for a property that belongs to a different member and the compiler stops you, because in that branch it knows the property is not there.
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number };
function area(shape: Shape): number {
if (shape.kind === 'circle') {
// In this branch it is definitely a circle, so there is no width.
return shape.width * 2;
}
return shape.width * shape.height;
}
console.log(area({ kind: 'circle', radius: 1 }));
What the compiler says
error TS2339: Property 'width' does not exist on type '{ kind: "circle"; radius: number; }'.
Exhaustiveness: the compiler tells you what you forgot
This is the payoff, and it is the thing that makes discriminated unions worth adopting as a habit. Add a member to the union and every place that handles it incompletely becomes an error — not a runtime surprise, an error at build time, listing exactly the files you need to visit.
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number }
| { kind: 'pentagon'; side: number };
// The return type says number, but with `pentagon` unhandled there is a path
// through this function that returns undefined. The compiler will not have it.
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rectangle':
return shape.width * shape.height;
}
}
console.log(area({ kind: 'circle', radius: 1 }));
What the compiler says
error TS2366: Function lacks ending return statement and return type does not include 'undefined'.
The annotated return type did that work. Without it the function would have quietly inferred number | undefined and the problem would have surfaced somewhere else. Here is the more deliberate version, which produces a better message and also fails at runtime if a bad value sneaks in from outside your typed code.
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rectangle':
return shape.width * shape.height;
default:
// If every case above is handled, `shape` here has type never, and
// assigning it to a never variable is fine. Miss a case and this line
// becomes an error naming the type you forgot.
return assertNever(shape);
}
}
function assertNever(value: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}
console.log(area({ kind: 'circle', radius: 2 }).toFixed(2));
console.log(area({ kind: 'rectangle', width: 2, height: 5 }));
// And it defends the boundary too: bad data from outside still throws here
// rather than returning undefined silently.
try {
area({ kind: 'hexagon', side: 1 } as unknown as Shape);
} catch (err) {
console.log('caught:', (err as Error).message);
}
Output
Modelling success and failure
A discriminated union is the natural way to describe an operation that might not work, and it is a real improvement on throwing, because the failure becomes part of the type rather than something the caller has to remember to think about.
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string };
function parseAge(input: string): Result<number> {
const n = Number(input);
if (Number.isNaN(n)) return { ok: false, error: `not a number: "${input}"` };
if (n < 0) return { ok: false, error: 'age cannot be negative' };
if (!Number.isInteger(n)) return { ok: false, error: 'age must be whole' };
return { ok: true, value: n };
}
for (const input of ['42', 'abc', '-1', '3.5']) {
const result = parseAge(input);
// The compiler will not let you touch .value without checking .ok first,
// which is the entire point.
if (result.ok) console.log(`${input.padEnd(5)} -> ${result.value}`);
else console.log(`${input.padEnd(5)} -> failed: ${result.error}`);
}
Output
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string };
function parse(input: string): Result<number> {
const n = Number(input);
return Number.isNaN(n) ? { ok: false, error: 'bad' } : { ok: true, value: n };
}
const result = parse('abc');
// `value` only exists on the success member. Reading it without checking `ok`
// is exactly the bug this pattern exists to prevent.
console.log(result.value);
What the compiler says
error TS2339: Property 'value' does not exist on type 'Result<number>'.
Choosing a discriminant
Any property whose type is a literal will work. Three conventions are common and the choice is mostly aesthetic.
kindortypewith string literals — the general default, and the most readable.okorsuccesswithtrue/false— ideal for two-outcome results, becauseif (result.ok)reads like ordinary code.- Presence of a property — works, and reads worse. Prefer an explicit tag.
One real constraint: the discriminant must be a literal type, not a general one. Typing the tag as string breaks the whole mechanism, because then the compiler cannot tell the members apart.
// `kind: string` instead of a literal, so checking it proves nothing and the
// compiler cannot narrow. This is a genuinely common mistake.
type Shape =
| { kind: string; radius: number }
| { kind: string; width: number; height: number };
function area(shape: Shape): number {
if (shape.kind === 'circle') {
return Math.PI * shape.radius ** 2;
}
return shape.width * shape.height;
}
console.log(area({ kind: 'circle', radius: 1 }));
What the compiler says
error TS2339: Property 'radius' does not exist on type 'Shape'. error TS2339: Property 'width' does not exist on type 'Shape'. error TS2339: Property 'height' does not exist on type 'Shape'.
Unions of arrays, and a trap
// An array whose elements are each either a string or a number.
type Mixed = (string | number)[];
// Either an array of strings, or an array of numbers, but not a mixture.
type Either = string[] | number[];
const mixed: Mixed = ['a', 1, 'b', 2];
const strings: Either = ['a', 'b'];
const numbers: Either = [1, 2];
console.log(mixed);
console.log(strings, numbers);
// The distinction is real: pushing a number into `Either` when it holds
// strings would be wrong, and the compiler treats the two very differently.
console.log('mixed length:', mixed.length);
Output
Union versus optional versus enum
Three things that look interchangeable and are not.
type Level = 'debug' | 'info' | 'error';
function log(level: Level, message: string): string {
return `[${level.toUpperCase()}] ${message}`;
}
console.log(log('info', 'started'));
console.log(log('error', 'stopped'));
// Look at the compiled output: the type is gone entirely and these are plain
// strings. Compare that with what an enum emits, in lesson 14.
What this compiles to
function log(level, message) {
return `[${level.toUpperCase()}] ${message}`;
}
console.log(log('info', 'started'));
console.log(log('error', 'stopped'));
// Look at the compiled output: the type is gone entirely and these are plain
// strings. Compare that with what an enum emits, in lesson 14.
Output
Your turn
- Write
type Weekdayas a union of the five working days, and a function taking one. Try calling it with'Sunday'and read the error. - Model an HTTP response as a discriminated union with three members: a 200 carrying a body, a 404 carrying nothing, and a 500 carrying an error message. Write a function that turns one into a display string using a
switch. - Add
assertNeverto that switch. Then add a fourth member to the union and confirm the compiler points you at the switch before you run anything. - Write the
Result<T>type from this lesson and use it for a function that divides two numbers, failing on a zero divisor. Try to read.valuewithout checking.ok. - Deliberately type a discriminant as
stringinstead of a literal and watch narrowing stop working. This is worth doing once so you recognise the symptom later.
Recap
- A union means “exactly one of these”, so only operations valid for every member are available until you narrow.
- A union of string literals replaces a loose
stringparameter and gives you autocomplete, typo checking and exhaustiveness for one line of effort. - A discriminated union — members sharing a tag property with differing literal types — is the workhorse pattern for shapes, results and states.
- Annotate the return type and add
assertNeverto the default branch; then adding a variant produces a compiler-generated list of everywhere you need to visit. - The discriminant must be a literal type. Typing the tag as
stringsilently disables the entire mechanism.