Learn TypeFirst · Part 3
Generics
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
- Implement
validateFormData(form: FormData): Result<ValidateError, FormData>. Then changenametoMaybe<string>inFormDataand follow the compile errors until everything works again — this "change the type, chase the squiggles" loop is the core TypeFirst workflow, and generics are what make it complete. - Generalize
RemoteDataStrfrom Part 2 intoRemoteData<E, T>and implementshowRemoteData<E, T>(data: RemoteData<E, T>): string. Bonus: let the caller pass custom functions to renderEandT— you'll rediscover whyshowMaybetook ashowargument. - Design a generic sum type for
ApiResponse— you are about three type parameters away from reinventing the real API contract type used across ts-bedrock.
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.