Learn TypeFirst · Part 2

Product & sum types

TypeFirst · AgileLabs Engineering · 7 min read

Part 1 taught your data to stop moving. This part teaches you to design it — and it starts with a counting game.

Cardinality: count your states

The cardinality of a type is how many legal values it has. Try these:

type Toggle = boolean                                  // 2
type Direction = "North" | "South" | "East" | "West"  // 4
type Pair = [boolean, boolean]                          // 2 × 2 = 4
type Email = string                                     // ∞ (!)

That last one should bother you: if Email has infinite values and real emails are a tiny subset, the type is mostly lies. (Fixing that is Part 5.) For now, the skill is just: read a type, count its states, compare with how many states your domain actually has.

Product types multiply

An object type is a product type: its cardinality is the product of its fields'.

type FormStatus = {
  isValid: boolean       // × 2
  isSubmitting: boolean  // × 2
  isSuccess: boolean     // × 2
  isFailure: boolean     // × 2  = 16 states
}

Sixteen representable states for a form with five real ones. The other eleven — success and failure at once, submitting while succeeded — are impossible states, and any code can accidentally construct one.

Sum types add

A union is a sum type: its cardinality is the sum of its variants'. That's the tool for "exactly one of these":

type FormStatus =
  | { _t: "Editing" }
  | { _t: "Invalid"; error: string }
  | { _t: "Submitting" }
  | { _t: "Success" }
  | { _t: "Failure"; error: string }   // exactly 5 states

The _t field is the discriminant — TypeScript uses it to narrow inside a switch, and the switch is exhaustive: add a variant later and every consumer fails to compile until it handles the new case. This pairing — tag to switch on, data attached only to variants that have it — is the single most used pattern in all of TypeFirst.

The two sum types you'll use every day

// "it might not be there" — and that's fine
type MaybeStr = string | null

// "it might fail" — and the caller should know why
type ResultStr =
  | { _t: "Err"; error: string }
  | { _t: "Ok"; value: string }

// "it's loading from somewhere" — all four truths, one value
type RemoteDataStr =
  | { _t: "NotAsked" }
  | { _t: "Loading" }
  | { _t: "Failure"; error: string }
  | { _t: "Success"; data: string }

The course rule of thumb, verbatim:

Use Result if you want to handle the error (most common). Use Maybe if you don't. Only throw when no one can handle it — a database connection failure is an exception; an invalid email is just a value.

(The full argument for that rule is in Never throw: Result types in production TypeScript.)

Exercises

Work the real exercises in Learn/Exercise/2-ProductSumTypes.ts, with the lecture notes in Lecture/ProductSumTypes.md. You may notice ResultStr and RemoteDataStr beg to be generalized — that itch is Part 3 — Generics.

← All posts