TypeScript generics & type-level programming

typescript · memo

In one line: Type parameters are solved by inference at each use, bounded by extends; conditional, mapped and template-literal types form a small pure functional language the checker evaluates — erased at runtime, limited by instantiation depth, and only as good as the inference it enables at call sites.

Download PDF Print view LaTeX source

TypeScript generics & type-level programming — figure 1

How it works

  • Constraint <T extends HasId> = upper bound, like Swift T: Protocol but structural; default <T = string>. Rule: a type parameter must appear twice (links input to output), else use unknown.
  • Inference collects candidates from each argument; literals widen unless T extends string (keeps 'a') or <const T> (5.0 — infers as if as const; arrays need a readonly unknown[] constraint). NoInfer<T> (5.4) removes one position from inference.
  • keyof T: union of keys (index signature string gives string|number). typeof v: value → type. T[K]: indexed access; T[number] = element type.
  • Conditional T extends U ? X : Y; infer R captures a piece of the match. With T still generic it is deferred — inside a generic body you can’t narrow it, hence casts in implementations.
  • Template literals (4.1): `on${Capitalize<K>}`, cross-product over unions, infer parses strings. Intrinsics: Uppercase, Lowercase, Capitalize, Uncapitalize.
  • Recursion: type Json = … | Json[] | {[k: string]: Json}. Tail-recursive conditional types go ≈1000 deep (4.5); else error 2589 “instantiation is excessively deep and possibly infinite”.
  • Swift: generics there are specialised/witness-table dispatched, invariant (except built-ins), and have no conditional/mapped types. some P ≈ a generic T; any P ≈ plain interface type (no box needed: structural).

Utility types from scratch (lib.es5.d.ts)

export {};              // module scope: shadows the global names
type Partial<T>  = { [K in keyof T]?: T[K] };
type Required<T> = { [K in keyof T]-?: T[K] };
type Readonly<T> = { readonly [K in keyof T]: T[K] };
type Pick<T, K extends keyof T> = { [P in K]: T[P] };
type Record<K extends keyof any, V> = { [P in K]: V };
type Exclude<T, U> = T extends U ? never : T;  // distributes
type Extract<T, U> = T extends U ? T : never;
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
type NonNullable<T> = T & {};                    // since 4.8
type ReturnType<T extends (...a: any) => any> =
  T extends (...a: any) => infer R ? R : any;
type Parameters<T extends (...a: any) => any> =
  T extends (...a: infer P) => any ? P : never;
type Awaited<T> = // simplified: real one handles any thenable
  T extends PromiseLike<infer V> ? Awaited<V> : T;

Example — what seniors write

type Events = { login: { id: string }; logout: undefined };
type Handlers = {                      // onLogin, onLogout
  [K in keyof Events as `on${Capitalize<string & K>}`]:
    (e: Events[K]) => void };
function get<T, K extends keyof T>(o: T, k: K): T[K] {
  return o[k]; }
type Params<S> = S extends `${string}:${infer P}/${infer R}`
  ? P | Params<`/${R}`>
  : S extends `${string}:${infer P}` ? P : never;
type P1 = Params<'/u/:id/post/:pid'>;  // 'id' | 'pid'
type DeepReadonly<T> = T extends (...a: any[]) => any ? T
  : { readonly [K in keyof T]: DeepReadonly<T[K]> };
function fsm<S extends string>(states: S[], start: NoInfer<S>) {}
fsm(['on', 'off'], 'of');              // error thanks to NoInfer
interface Getter<out T> { get: () => T }
interface Setter<in T>  { set: (v: T) => void }

Interview traps

  • “{a:1} is assignable to the constraint of T, but T could be instantiated with a different subtype” — you returned a new object where the caller’s exact T was promised.
  • Omit/keyof on a union keep only common keys: distribute first — T extends any ? Omit<T, K> : never.
  • IsNever<T> = T extends never ? … yields never for never: write [T] extends [never].
  • Arrays are covariant only through method bivariance: Dog[] as Animal[] then push(cat) compiles.
  • Clever types cost compile time and error readability; prefer overloads or a plain union when a call site doesn’t gain inference.

Remember

Constrain, infer, then compute: conditional = if, mapped = for-each, infer = pattern bind, recursion = loop.

Likely questions

  1. Distributive conditional? — a naked T maps over each union member; wrap in [T] to stop it.
  2. Covariant vs contravariant? — outputs keep the subtype arrow, inputs flip it, both = invariant.
  3. Why NoInfer? — so a default/secondary argument can’t widen T.
  4. const type parameter? — literal/readonly inference without as const at every call.
  5. Implement Pick/ReturnType? — see the block above.