Learn TypeFirst · Part 2
Product & sum types
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:
UseResultif you want to handle the error (most common). UseMaybeif you don't. Onlythrowwhen 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
- Write
showErrorMessage(e: ValidateError): stringfor"INVALID_EMAIL" | "INVALID_PASSWORD" | "INVALID_USERNAME"— let the exhaustive switch guide you. - Write
showApiStatus(d: RemoteDataStr): string— one message per variant, noifchains. - Design
FormStatusas a sum type for a real form, then writeisDisabled(status: FormStatus): booleanfor the submit button. Notice you never check two flags at once — there aren't two flags. - Write
validateEmailResult(email: string): ResultStrand itsMaybetwin. Feel the difference in what the caller learns.
ResultStr and RemoteDataStr beg to be generalized — that itch is Part 3 — Generics.