Algebraic Data Types in JS
Algebraic Data Types (ADTs) let you build new types by combining existing ones in two fundamental ways: you either get all the pieces at once, or you get exactly one of several alternatives. TypeScript’s type system gives you both patterns, and understanding them will change how you design data structures in JavaScript codebases.
Product Types: All the Pieces at Once
A product type combines values so that a single instance holds all fields simultaneously. You already use product types every day — objects and arrays are the most common examples.
Objects are product types. A Point contains both an x and a y coordinate, and you can’t have one without the other:
const point = { x: 10, y: 20 };
// point.x // 10
// point.y // 20
Tuples are fixed-length product types. A [string, number] tuple always contains exactly two values — a string in position zero and a number in position one:
const pair = ['age', 42];
const [label, value] = pair;
// label // 'age'
// value // 42
Arrays are product types too, though unbounded. A number[] contains all the numbers in the array at once, not just one of them.
The “product” name comes from how the possibilities multiply. A boolean has 2 possibilities and a number has roughly 2^53 possibilities. A { flag: boolean, n: number } object has 2 × 2^53 possibilities — the total is the product of the individual type’s possibilities. That is why they are called product types.
Sum Types: One of Many Alternatives
Sum types are the other half of the picture. A value of a sum type is exactly one of several variants, and the type system tracks which one you have. TypeScript expresses sum types through union types with a discriminant — also called a tagged union or discriminated union.
The classic example is modeling something that might not exist. You could use null or undefined, but that collapses all cases into a single type and forces runtime checks everywhere. A sum type makes every case explicit:
// { kind: 'just', value: T } | { kind: 'nothing' }
const just = (value) => ({ kind: 'just', value });
const nothing = { kind: 'nothing' };
The kind property is the discriminant. TypeScript uses it to narrow the type inside conditional branches:
function describeMaybe(m) {
if (m.kind === 'just') {
return `Value: ${m.value}`; // TypeScript knows m.value exists here
}
return 'Nothing';
}
// describeMaybe(just(5)) // 'Value: 5'
// describeMaybe(nothing) // 'Nothing'
Without the kind check, TypeScript would refuse to access m.value because it cannot rule out the nothing variant. The discriminant is what makes this safe.
The Result Type: Handling Failure Explicitly
The Result type is one of the most practical applications of sum types in JavaScript. Instead of throwing exceptions, a function returns either a success value or an error:
// { ok: true, value: T } | { ok: false, error: E }
const ok = (value) => ({ ok: true, value });
const err = (error) => ({ ok: false, error });
With a discriminant like ok, you get type-safe error handling without try/catch:
function parseAge(input) {
const n = Number(input);
return Number.isNaN(n)
? err('Not a number')
: ok(n);
}
const result = parseAge('abc');
if (result.ok === true) {
console.log(result.value); // TypeScript knows this is number
} else {
console.error(result.error); // TypeScript knows this is string
}
The key advantage over throwing exceptions is that the error case is part of the return type. Callers cannot ignore it — TypeScript will flag any code path that does not handle both branches.
Either: Left and Right
Either is a variant of Result that does not commit to a naming convention. By convention, the left side holds an error and the right side holds a success value:
// { left: L } | { right: R }
const left = (l) => ({ left: l });
const right = (r) => ({ right: r });
This pattern composes well with chained operations. You can write a function that runs if the left branch is taken, or passes the right value through unchanged:
const mapRight = (fn) => (e) =>
e.right !== undefined
? right(fn(e.right))
: e;
const double = (n) => n * 2;
mapRight(double)(right(5)); // { right: 10 }
mapRight(double)(left('oops')); // { left: 'oops' }
Exhaustive Checking
One of the biggest benefits of discriminated unions is exhaustive checking. If you add a new variant to a union type, TypeScript will flag any switch or if statement that does not handle it:
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number };
function area(shape) {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.side ** 2;
// If you add { kind: 'triangle' }, TypeScript warns here
}
}
If you later add a triangle variant and forget to update area, TypeScript will report a compile error. This makes refactoring significantly safer — the compiler tells you every place that needs updating.
Why This Matters for JavaScript Developers
ADTs let you model exactly the states that are possible in your domain, and no others. Without them, you end up with types like User | null | undefined, where every property access requires a null check. With ADTs, you define precisely what cases exist and what data accompanies each one.
The kind discriminant is the bridge between the type system and runtime checks. It gives TypeScript enough information to narrow types safely, and it gives you a clean way to handle each case without resort to fragile optional chaining chains.
When combined with function composition, ADTs shine. A function that takes a Result and returns a Result composes cleanly with other Result-producing functions. You chain them together without needing to scatter try/catch blocks across your call sites.
See Also
- Function Composition in JavaScript — how ADTs slot into composable function pipelines
- Currying and Partial Application — partial application pairs naturally with ADT helpers like
mapMaybe - TypeScript Narrowing — narrowing is the mechanism that makes discriminated unions safe to access