Part 6 · 2 chapters · ~20 min

Typing a Library Versus an App

The two disciplines (a library's types are its product; an app's types serve its engineers and its domain), overloads versus lookup generics, branded types as nominal typing on demand, Result types at boundaries, reading the surface as a stranger, and the most valuable pattern in the language: discriminated unions with exhaustive checks, the mapped form, and the four reasons it breaks.

13

A library exports a surface; an app exports nothing

code
// a library surface done well: explicit types, brands, overloads where shapes are few, a Result at the boundary, exhaustive internally
export type UserId = string & { readonly __brand: 'UserId' }                       // nominal on demand
export const UserId = (s: string): UserId => { if (!/^u_[a-z0-9]{8}$/.test(s)) throw new TypeError('bad UserId'); return s as UserId }   // the only way to make one

export type Result<T, E = ClientError> = { ok: true; value: T } | { ok: false; error: E }
export type ClientError = { kind: 'network'; retryable: true } | { kind: 'http'; status: number; body: unknown } | { kind: 'parse'; message: string }

export interface Client {
  getUser(id: UserId): Promise<Result<User>>                                        // explicit: never an inferred internal shape
  on(event: 'connect', handler: () => void): () => void                              // overloads: few, distinct shapes, documented
  on(event: 'error', handler: (e: ClientError) => void): () => void
}

// internally: exhaustive over the error union; adding a kind fails to compile here
export function describe(e: ClientError): string {
  switch (e.kind) {
    case 'network': return 'Could not reach the server.'
    case 'http':    return `The server answered ${e.status}.`
    case 'parse':   return `The response could not be read: ${e.message}`
    default:        return assertNever(e)
  }
}
export function assertNever(x: never): never { throw new Error('unexpected: ' + JSON.stringify(x)) }

// the app side consuming it: the Result must be handled; the brand must be constructed; the states are a union
const r = await client.getUser(UserId('u_3f9a2k1x'))
if (!r.ok) { show(describe(r.error)); return }                                     // r.error: ClientError; below, r.value: User
render(r.value)
two disciplines
  1. The library: explicit return types on every export (inference leaks internal shapes); exported types for every shape a consumer must name; type tests on the surface (part 4); generics designed for strangers (part 2's smell test); overloads for the common call shapes; errors readable by someone who did not write the code (a named brand over an anonymous intersection); no any in the surface. The .d.ts is reviewed as an API.
  2. The app: strict everything (part 8); inference everywhere internal, annotations where they document; internal shapes refactored freely with the checker as the net; the type budget spent on the domain (part 7); no declarations emitted; isolatedModules for the fast loop (part 5).
  3. Overloads for a few distinct call shapes, most specific first, the implementation hidden; a lookup generic (on<K extends keyof Events>) when the shapes are many and regular.
  4. Branded types: string & { readonly __brand: "UserId" } is nominal on demand: a plain string is refused, a UserId is a string where needed, and the only way to make one is the constructor that validates. Libraries brand their identifiers so consumers cannot mix them; apps brand money (the Trust course part 4), ids per entity and validated inputs.
  5. Result types put failure in the signature where a throw is invisible: at boundaries where failure is expected (a parse, a fetch); throw for bugs. Exhaustive handling (next chapter) is what makes it pay; a Result ignored is worse than a throw.
  6. Reading the surface as a stranger: generate the .d.ts and read it as a consumer: every anonymous type is something they cannot name, every any a hole, every generic needing type arguments a failed inference, every internal name a leak.
A LIBRARY EXPORTS A SURFACE; AN APP EXPORTS NOTHING
the two disciplines, and where each spends its type budget
swipe the figure sideways, or tap expand for full screen
1/6
the library discipline
The library discipline: explicit return types on every export (inference leaks internal shapes and changes with implementation); exported types for every shape a consumer must name; a public surface tested with type tests (part 4); generics designed for strangers (part 2's smell test at call sites you will never see); overloads for the common call shapes; errors that read well (a branded type with a descriptive name over an anonymous intersection); no any in the surface, ever.
14

Discriminated unions and exhaustive checks

the compiler enumerates the cases you forgot
  1. The union: one object type per state, each carrying only the fields that exist in that state (no data while loading, no error while ok). The alternative with optional everything allows the impossible and forces checks everywhere.
  2. The switch on the discriminant narrows each case (part 1) and the default sees never when every case is handled; add a state and every switch that does not handle it fails to compile. The single highest-value line in a codebase is default: assertNever(x).
  3. assertNever(x: never): never: the parameter type is the check; the throw is the runtime guard for a value the types said could not exist (data from an older client: part 8's migrations). satisfies never is the expression form.
  4. Exhaustive records: a mapped type with a handler per state ({ [K in Request["status"]]: (r: Extract<Request, { status: K }>) => void }): a missing key is a compile error and each handler receives the narrowed member; satisfies keeps the keys checked without widening. The right shape for reducers and renderer maps.
  5. Where it breaks, always for one of four reasons: the discriminant widened to string (part 0); the union built from a loosely typed response (parse at the boundary, part 7); an if/else chain with no exhaustive end; a non-literal discriminant (a string from a lookup; a class without a kind).
  6. Beyond status: parser tokens, UI modes, payment sub-states (the Trust course part 3), API results, events (the typed emitter from part 3), reducer actions. Anything with modes is a union; the pattern is the domain model made checkable, and part 7 is built on it.
the exercise
Find a type in your app with three or more optional properties that are never all present at once. Rewrite it as a union with a discriminant, add assertNever to one switch, and count the impossible states the old type allowed.
DISCRIMINATED UNIONS AND EXHAUSTIVE CHECKS
the state as a union, the switch that the compiler completes, and the never that catches the missing case
swipe the figure sideways, or tap expand for full screen
1/6
the union
The union: type Request = { status: "idle" } | { status: "loading"; startedAt: number } | { status: "ok"; data: Data } | { status: "error"; error: Error; retryAt?: number }. Each state carries only the fields that exist in that state: there is no data while loading and no error while ok. The alternative ({ status: string; data?: Data; error?: Error }) allows the impossible (loading with data) and forces checks everywhere.