Learn TypeFirst · Part 3

Generics

TypeFirst · AgileLabs Engineering · 6 min read

By the end of Part 2 you had written something like this — and probably felt the smell:

type MaybeStr = string | null
type MaybeNum = number | null

type User = {
  name: MaybeStr
  age: MaybeNum
}

Two types, identical shape, different payload. Copy-pasting a pattern per payload type doesn't scale — and the duplication isn't just typing effort; every helper function you write for MaybeStr has to be written again for MaybeNum. A generic abstracts the pattern over the payload:

type Maybe<T> = T | null

type User = {
  name: Maybe<string>
  age: Maybe<number>
}

Think of Maybe as a function at the type level: it takes a type, returns a type. Crucially, nothing is given up — Maybe<string> is exactly as checked as MaybeStr was. Generics are reuse without the usual price of reuse (loosened types).

Generic functions

The same abstraction works for functions. One signature, fully typed at every call site:

function showMaybe<T>(m: Maybe<T>, show: (t: T) => string): string {
  return m == null ? "nothing" : show(m)
}

showMaybe("hi", (s) => s)        // T inferred as string
showMaybe(42, (n) => String(n))  // T inferred as number

You rarely write the type argument — inference fills it in. The discipline is in the signature: a generic function promises to work for all T, which means its body can't peek inside T. That restriction is a feature. There are very few ways to implement showMaybe wrongly without the compiler noticing.

Two parameters: Result

Part 2's ResultStr / ResultNum duplication generalizes the same way — twice over, once for the error and once for the value:

type Result<E, T> =
  | { _t: "Err"; error: E }
  | { _t: "Ok"; value: T }

type UserForm = {
  nameField: Result<string, string>
  ageField: Result<string, number>
}

And now error codes can be precise per use: Result<"INVALID_EMAIL" | "INVALID_USERNAME", FormData>. One generic shape, infinitely many exact types.

Exercises

From this part on, two new course rules apply everywhere: always use Maybe for nullable data, always use Result when failure is possible. You now have the vocabulary; the rest of the series is about using it at the system's edges.

Continue the course with Learn/Exercise/3-Generic.ts and Lecture/Generic.md. Next: Part 4 — Decoders, where the outside world finally shows up.

← All posts