jsguides

Algebraic Data Types in JavaScript: Product and Sum Types

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 the most natural product type in JavaScript, but tuples give you a stricter alternative. A Point contains both an x and a y coordinate, and you cannot have one without the other:

const point = { x: 10, y: 20 };
// point.x  // 10
// point.y  // 20

When you destructure a tuple, TypeScript knows the type of each element by position, so label is a string and value is a number without any type annotation. This is why tuples work so well for useState hooks and other patterns where position carries meaning.

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, and 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. When you check m.kind === 'just', the compiler knows m.value exists on that branch, and on the else branch it knows only the nothing shape remains. This is what makes discriminated unions safe at compile time without runtime assertions:

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 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. The parseAge function below returns either { ok: true, value: number } on success or { ok: false, error: string } on failure. The caller checks result.ok to narrow the type, and TypeScript enforces that both branches are handled:

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. mapRight applies a transformation only when the value is on the right side, leaving the left side untouched. This means you can build a pipeline of operations that short-circuit on the first error, similar to how Promise chains skip to the nearest .catch:

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

The mapRight pattern is useful, but discriminated unions offer a stronger safety net: exhaustive checking. TypeScript tracks every variant in the union and warns you at compile time if any case is unhandled, which means refactoring becomes mechanical rather than guesswork.

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.

Model the states you actually have

Algebraic data types are most useful when they mirror the real outcomes of a computation. If a function can succeed, fail, or remain pending, model those outcomes directly instead of forcing every case through one generic object. That makes the code easier to read and easier to test. Each branch gets a name, and each name tells the reader what the program knows at that moment. The clarity is often worth more than the extra structure.

Pair well with pattern matching

Once a value is represented as a union of shapes, pattern matching becomes the natural way to consume it. The branching logic moves from ad hoc property checks into explicit cases, and that helps you notice missing handling earlier. Even without a dedicated pattern matching feature, simple discriminant checks can give you much of the same benefit. The important point is that the variants stay visible rather than being collapsed into one ambiguous object.

Keep variants small

Each variant in an algebraic data type should carry only the fields that make sense for that case. If every branch shares a giant object with lots of optional values, the shape loses much of its value. Smaller variants are easier to validate and easier to serialize. They also make reviews cleaner, because each case shows only the data that matters. That discipline keeps the model honest and prevents one type from becoming a catch-all container.

Adopt them gradually

You do not need to convert an entire codebase at once. Start with the places where null, ad hoc status flags, or loose objects already cause confusion. Replace one of those with a clearer type and see how the call sites change. That gradual path gives the team time to learn the style without a big rewrite. It also shows where the model is helping and where it may be too heavy for the problem.

See Also