Dataslope logoDataslope

Algebraic Data Types

Modeling possibility with sums and products — the type-level vocabulary that makes functional design precise.

When you sit down to model a domain (an order, a user, a request, a payment), you are choosing a shape. The shape determines which states are possible, which are impossible, and which mistakes the compiler can catch for you.

Algebraic data types (ADTs) are the type-level vocabulary functional programmers use to build those shapes. There are exactly two atomic moves — and and or — and you combine them to express anything you need.

Two operations: and (products) and or (sums)

  • Product = "A and B". A pair. A record. A tuple. A User has a name and an age.
  • Sum = "A or B". A choice. A union with a tag. A Shape is a Circle or a Rectangle or a Triangle.

That's the whole algebra. Every data model is some combination of these two operations.

Why "algebraic"?

If you count the values a type can have, products multiply and sums add. A boolean has 2 values; a pair of booleans has 2 × 2 = 4. A choice between a boolean and a string has 2 + (all strings). The cardinality literally follows the rules of arithmetic — hence algebraic data types.

Products in TypeScript: records and tuples

Code Block
TypeScript 5.7

A product type is "all of these fields, simultaneously". TypeScript already has them — they're records and tuples. There's nothing novel here.

Sums in TypeScript: discriminated unions

The interesting half. A sum says "one of these alternatives". In TypeScript, we model it as a discriminated union — a union of record types, each with a unique tag field.

Code Block
TypeScript 5.7

A few things to notice:

  1. The compiler narrows s to a specific variant inside each case. You can access s.radius only after confirming s.kind === "circle". There is no need for type assertions or as Circle.
  2. If you add a new variant (square) and forget to handle it, the compiler will warn — provided you use the never trick (next section).

Exhaustiveness checking with never

Code Block
TypeScript 5.7

Try adding a { kind: "triangle"; base: number; height: number } variant to Shape and see how the compiler reacts. This is the trick that turns "I think this switch covers everything" into "the compiler will prove it does".

Modeling possibility precisely

The most common beginner mistake is to model alternatives with optional fields and booleans. That works, but it allows nonsense states to exist in the type. Sums let you forbid them.

Bad: "any of these fields might be set"

Code Block
TypeScript 5.7

The type allows "active and suspended" — a nonsense state. Every function that takes BadUser now has to check at runtime: is this really suspended?

Good: a sum that forbids nonsense

Code Block
TypeScript 5.7

Now "active and suspended" is unrepresentable. The data carried by each variant (reason, until, deletedAt) is tied to the case that needs it — you can never have suspendedUntil without also being suspended.

This is the slogan "make illegal states unrepresentable" in action.

A small zoo of useful sums

You'll use these shapes constantly. We define each here; later chapters explore them in depth.

Option: a value or nothing

Code Block
TypeScript 5.7

Replaces null and undefined with a type the compiler tracks.

Result: success or failure

Code Block
TypeScript 5.7

Replaces exceptions with a type that forces the caller to handle the error case.

Tree: recursive sums

Code Block
TypeScript 5.7

A recursive sum: a tree is either empty, or a node holding a value and two subtrees. Three lines of definition; the entire algorithm (walking it, transforming it, folding it) follows from switch.

Designing with sums and products together

Real domain types interleave the two operations.

In TypeScript:

Code Block
TypeScript 5.7

Sums nest inside sums (Event.payment_received.method is itself a sum). Records carry the fields appropriate to each variant. The compiler tracks both layers automatically.

A multi-file challenge

Challenge
TypeScript 5.7
Type a small geometry library

In shape.ts, define a discriminated union Shape that has four variants:

  • circleradius
  • rectanglewidth, height
  • trianglebase, height
  • squareside

Implement area(s: Shape): number. Use a switch and the never exhaustiveness pattern in the default case.

In main.ts, the test data is shipped; do not modify it.

Expected output

circle:   12.566370614359172
rectangle: 12
triangle: 15
square:   16

QuestionSelect one

Why is a discriminated union ("sum type") often a better model than a record with several optional fields?

Unions compile faster

Unions use less memory at runtime

A discriminated union ties each piece of data to the variant that needs it, so impossible combinations of fields cannot exist

Unions don't need a "kind" tag

On this page