Skip to content

3.7.5 — Type-Level Programming

Here is TypeScript's genuinely unusual claim to fame: the type layer is itself a small functional programming language. It has values (types), functions (generic type aliases), conditionals (extends ? :), pattern matching (infer), iteration (mapped types), string manipulation (template-literal types), and recursion. Library authors use it to make ReturnType<F> or a router's params object compute themselves from your code. This page teaches that language properly: each construct from need → mechanism → the standard utilities rebuilt from scratch → real-world type machines — and, just as important, when not to write any of it. How do we generate a TypeScript interface from an object? How does infer work? [EQ-181]

1. typeof, revisited: types from values

The gateway drug. You have a value; you want its type without writing it twice:

typescript
const config = { host: "localhost", port: 5432, secure: false };

type Config = typeof config;
// → { host: string; port: number; secure: boolean } — derived, can never drift

const ROLES = ["admin", "editor", "viewer"] as const;
type Role = typeof ROLES[number];     // "admin" | "editor" | "viewer"

Type-level typeof reads a value's inferred type (distinct from the runtime typeof operator — 3.6.7). The second idiom — as const array, index by [number] — is the single-source union from 3.7.3. Everything below builds on this move: derive, don't duplicate.

2. Mapped types: iteration over keys

A mapped type is a for…each key loop at the type level:

typescript
type MyPartial<T>  = { [K in keyof T]?: T[K] };          // add ? to every property
type MyRequired<T> = { [K in keyof T]-?: T[K] };         // -? REMOVES optionality
type MyReadonly<T> = { readonly [K in keyof T]: T[K] };  // add readonly everywhere
type Mutable<T>    = { -readonly [K in keyof T]: T[K] }; // and strip it

Read [K in keyof T] as "for each key K of T"; the value position says what to store (T[K] = keep the original — 3.7.4's indexed access); +/- prefixes add or remove the ? and readonly modifiers. You have now implemented four of the standard utility typesPartial (update payloads), Required, Readonly<T> (compile-time immutability: assignment to any property errors; shallow; erased — the intent-enforcing cousin of runtime Object.freeze, 3.6.4), and the inverse nobody ships but everyone eventually needs. What is the significance of Readonly<MyInterface> in TypeScript? [EQ-166]

Two more standard utilities fall out of mapping over a chosen key set:

typescript
type MyRecord<K extends PropertyKey, V> = { [P in K]: V };
type MyPick<T, K extends keyof T>       = { [P in K]: T[P] };

type PublicUser = MyPick<User, "id" | "name">;   // subset by key union

Key remapping with as upgrades the loop: transform each key as you go, and drop keys by mapping them to never:

typescript
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
};
type UserGetters = Getters<{ name: string; age: number }>;
// → { getName: () => string; getAge: () => number }   — an API computed from a shape

3. Conditional types: if at the type level

typescript
type IsString<T> = T extends string ? true : false;

Conditional types ask an assignability question (3.7.4's relation) and choose a branch. Their superpower is distributivity: when the checked type is a naked type parameter and you pass a union, the conditional runs per member and unions the results:

typescript
type MyExclude<T, U> = T extends U ? never : T;      // never = "drop this member"
type MyExtract<T, U> = T extends U ? T : never;
type MyNonNullable<T> = T extends null | undefined ? never : T;

type T1 = MyExclude<"a" | "b" | "c", "a">;   // runs 3× → never | "b" | "c" → "b" | "c"

never acts as the filter (3.7.2's empty type vanishing from unions) — that's the whole trick behind Exclude/Extract/NonNullable, all three now rebuilt. (When distribution is unwanted, wrap both sides in a tuple — [T] extends [U] ? … : … — the standard idiom.)

4. infer: pattern matching that captures

Conditionals can also destructure: infer declares a type variable inside the pattern and captures whatever sits at that position:

typescript
type ElementOf<T>  = T extends (infer U)[] ? U : never;        // unwrap arrays
type MyReturnType<F> = F extends (...args: any[]) => infer R ? R : never;
type MyParameters<F> = F extends (...args: infer A) => any ? A : never;
type MyAwaited<T>  = T extends Promise<infer V> ? MyAwaited<V> : T;  // recursive unwrap

type N = ElementOf<number[]>;                        // number
type R = MyReturnType<(x: string) => Date>;          // Date
type V = MyAwaited<Promise<Promise<string>>>;        // string — recursion flattens

Read F extends (...args: any[]) => infer R ? R : never as: if F matches the shape "function returning something," name that something R and produce it. This is how ReturnType, Parameters, Awaited, InstanceType are actually implemented — you've now rebuilt the standard library's core. The practical value isn't writing these daily; it's that library signatures stop being hieroglyphics: when a Zod or TanStack type five layers deep says infer, you can read it as pattern-match-and-capture.

5. Template-literal types: computing with strings

Type-level strings interpolate, just like runtime template literals (3.6.7) — and they distribute over unions, generating combinations:

typescript
type Method = "GET" | "POST";
type Path   = "/users" | "/orders";
type Route  = `${Method} ${Path}`;
// → "GET /users" | "GET /orders" | "POST /users" | "POST /orders"

Combined with infer inside the pattern, strings can be parsed at the type level. The flagship real-world machine — extracting route parameters, as Express/Remix/Hono typings do:

typescript
type PathParams<P extends string> =
  P extends `${string}:${infer Param}/${infer Rest}`     // a :param mid-path?
    ? { [K in Param | keyof PathParams<`/${Rest}`>]: string }   // capture + recurse
    : P extends `${string}:${infer Param}`               // a :param at the end?
      ? { [K in Param]: string }
      : {};                                              // no params left

type P = PathParams<"/users/:userId/posts/:postId">;
// → { userId: string; postId: string }     — computed FROM the route string

Change the route string, and every handler's params type updates itself — the "derive, don't duplicate" thesis at full power. The intrinsic helpers Uppercase/Lowercase/Capitalize/Uncapitalize round out the toolkit (you met Capitalize in the getters example). Recursion (used twice above: MyAwaited, PathParams) is bounded by the checker — roughly 50 levels deep, with tail-recursion elimination extending common cases (limits are engine-version-specific; treat exact numbers as illustrative) — enough for paths and JSON trees, not for arithmetic stunts.

One recursive classic completes the tour — deep immutability, the honest version of Readonly:

typescript
type DeepReadonly<T> = T extends (...a: any[]) => any ? T
  : T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;

6. The expert lens

The audience for type-level code is call sites, not the type itself. A gnarly PathParams is justified by what consumers see: params.userId autocompleting, typos failing CI, route changes propagating. That's the test for writing any of this: does it make many call sites simpler and safer than the equivalent hand-written types? Route tables, event maps, ORM schemas, API clients — yes, hundreds of call sites amortize one hard type. A one-off shape — no; write the plain interface. Type-level cleverness that saves the author ten lines while costing every reader comprehension is negative-value code.

Know the failure modes: error messages and checker time. Deep conditional/recursive types produce errors that name the machinery, not the mistake ("Type X is not assignable to { [K in Param | keyof PathParams<...> …") — mitigate by naming intermediate types (each alias becomes a readable frame in the error) and adding extends constraints early so misuse fails at the door with a simple message. And types are computed during every check: pathological types (large unions × template distribution multiply combinatorially) measurably slow tsc and the editor. tsc --diagnostics / --generateTrace find the hot types — yes, type-level code gets profiled too (3.7.1's checker is a program you can make slow).

This is the ecosystem's essential layer. Zod's z.infer (3.7.7), TanStack Router's typed paths, Prisma's generated client, tRPC's end-to-end inference — each is mapped + conditional + infer + template literals, industrialized. You now know the four primitives every one of them reduces to; that's the difference between using magic and reading it.

Next: 3.7.6 returns to earth — classes as types, decorators, and the declaration files that carry types across package boundaries.

Recall

  • Thesis: the type layer is a functional language — derive, don't duplicate. Type-level typeof reads value types; as const + [number] derives literal unions.
  • Mapped types [K in keyof T] iterate keys; +/- toggle ?/readonly (⇒ Partial, Required, Readonly — shallow, erased); map over a key union ⇒ Pick/Record; key remapping as transforms keys (`get${Capitalize<K>}`) and drops them via never.
  • Conditional types T extends U ? A : B branch on assignability; naked-parameter unions distribute (per-member, results unioned) and never filters ⇒ Exclude/Extract/NonNullable; [T] extends [U] blocks distribution.
  • infer captures a position inside the matched pattern ⇒ ReturnType/Parameters/Awaited (recursive unwrap). Template-literal types interpolate and distribute; with infer they parse strings ⇒ route-param extraction; intrinsics Capitalize & co.; recursion is depth-limited.
  • Judgment: justify machinery by call-site payoff (route tables, event maps, schemas); name intermediates for sane errors; watch checker performance; otherwise write the plain interface.

Self-test: Implement Partial, Pick, Exclude, and ReturnType from memory. Why does Exclude<"a"|"b", "a"> work — name the two mechanisms. What does key remapping with as enable — give the getters example. Walk how PathParams parses /u/:id — which construct does each step use? State the test for whether type-level code is worth writing.

Quiz Bank

FoundationalWhat is a mapped type? Implement Partial, Readonly, and Pick and explain the modifier syntax.

A mapped type iterates a union of keys — usually keyof T — building an object type member by member: { [K in keyof T]: T[K] } is the identity. The value slot may transform (() => T[K]), and prefix operators on the modifiers add or strip them: ? / -? for optionality, readonly / -readonly for immutability. Hence: Partial<T> = { [K in keyof T]?: T[K] } (all optional — update payloads); Readonly<T> = { readonly [K in keyof T]: T[K] } (all immutable at compile time — shallow and erased, versus runtime Object.freeze); Pick<T, K extends keyof T> = { [P in K]: T[P] } (map over a chosen key union instead of all keys). Record<K, V> = { [P in K]: V } is the same loop with a constant value type. This one construct implements most of the standard utility library.

FoundationalHow do conditional types work, and what is distributivity? Derive Exclude.

T extends U ? A : B evaluates the assignability question (3.7.4) and selects a branch — an if over types. Distributivity: when the checked type is a naked type parameter and the argument is a union, the conditional evaluates once per member, unioning the results. Exclude<T, U> = T extends U ? never : T: for Exclude<"a"|"b"|"c", "a"> it runs three times — "a" matches → never; "b", "c" don't → themselves — giving never | "b" | "c", and since never vanishes from unions (3.7.2's empty type), the result is "b" | "c". The never-as-filter trick is the engine of Extract, NonNullable, and key-dropping in remapped types. To prevent distribution (test the union as a whole), wrap both sides in tuples: [T] extends [U] ? … : ….

AppliedWhat does infer do? Implement ReturnType and Awaited and explain how to read them.

infer declares a capture variable inside a conditional type's pattern — pattern matching with destructuring at the type level. ReturnType<F> = F extends (...args: any[]) => infer R ? R : never reads: if F matches "function returning something," bind that something to R and produce it. Awaited<T> = T extends Promise<infer V> ? Awaited<V> : T adds recursion: unwrap one Promise layer, then recurse until no promise remains — so Promise<Promise<string>> yields string (matching await's runtime flattening, 3.6.8). Same mechanism: Parameters (infer A in the args position), InstanceType (infer on the construct signature), ElementOf<T> = T extends (infer U)[] ? U : never. Reading rule for library types: find the shape being matched, find what infer names, and the type is "extract that from anything matching this."

InterviewWhat are template-literal types capable of? Sketch how typed route params work.

Three capabilities. Interpolation: `${Method} ${Path}` builds string types. Distribution: interpolating unions yields the cross-product — "GET"|"POST" × two paths → four route literals (powerful; also the classic checker-performance trap when unions are large). Parsing: combined with infer inside the template pattern, string types can be deconstructedP extends `${string}:${infer Param}/${infer Rest}` captures the parameter name and the remainder, and recursion walks the rest of the path; a mapped type then turns captured names into an object: PathParams<"/users/:userId/posts/:postId"> computes { userId: string; postId: string }. That's precisely how Express 5 / Hono / TanStack Router derive req.params from the route string — change the path, every handler's params type updates, typos become compile errors. Intrinsics (Capitalize, Uppercase, …) handle casing, e.g. mapped-type key remapping to `get${Capitalize<K>}` getters.

StaffA shared package's clever types have started producing 3-second editor hangs and unreadable errors downstream. How do you triage, fix, and set policy?

Triage — measure, don't guess: tsc --extendedDiagnostics for aggregate check time; tsc --generateTrace + the analyzer to find which types burn it (usually: template-literal cross-products over big unions, unbounded recursion, or a giant union re-distributed through several conditionals); editor hangs typically share the same hot types via the language server (3.7.1's "same checker, three consumers").

Fix patterns: name intermediate types (each alias is both a cache point for the checker's memoization and a readable frame in downstream errors — the single highest-leverage fix for both symptoms); constrain generic entry points early (P extends string, bounded unions) so misuse fails at the door with a one-line message instead of unwinding the machinery; cap distribution (tuple-wrap where union-splitting isn't needed); replace type-level recursion with generated code where the source is static (a codegen step emitting plain interfaces — Prisma's choice — trades build-time generation for zero check-time cost); and delete cleverness whose call-site payoff never materialized — a plain interface is a fix.

Policy: the package's public types are an API with a performance budget — add a CI check on tsc --extendedDiagnostics check-time regression, require that exported type utilities come with (a) a doc line stating the call-site benefit and (b) a "misuse produces this error" test (via @ts-expect-error fixtures); internal-only cleverness stays unexported. Frame for the team:

type-level code is code — it has readers, runtime (the checker), profilers, and budgets like any other.

Flashcards

FlashMapped type + modifiers

{ [K in keyof T]: … } iterates keys; ?/-? and readonly/-readonly toggle modifiers → Partial, Required, Readonly, Mutable.

FlashKey remapping

[K in keyof T as NewKey] — transform keys (template literals, Capitalize) or drop them (as never). Getters<T> in one line.

FlashDistributive conditional

Naked parameter + union ⇒ evaluate per member, union results; never filters out. Exclude/Extract/NonNullable. Block with [T] extends [U].

Flashinfer

Capture-variable inside a conditional's pattern — type-level destructuring. ReturnType, Parameters, Awaited (recursive), ElementOf.

FlashTemplate-literal types

Interpolate, distribute (cross-products), and — with infer — parse strings. Route-param extraction; Capitalize & co. for casing.

FlashWhen to write type-level code

Call-site payoff test: many sites get safer/simpler (routes, event maps, schemas) → yes. One-off shape → plain interface. Name intermediates; watch check time.

Scenario Drill

DrillYour team hand-maintains three artifacts for a REST client: an endpoints list, a request/response interface file, and a mock server — and they drift constantly. Endpoints look like GET /users/:id → User. Design the type-level architecture that collapses them to one source of truth, using this page's constructs, and state where you'd stop.

One source, everything derived. Declare the API once as an as const value — runtime-usable and type-readable (section 1):

typescript
const API = {
  "GET /users/:id":      { response: UserSchema },
  "GET /users":          { response: UserListSchema },
  "POST /orders":        { body: OrderCreateSchema, response: OrderSchema },
} as const;

Schemas are Zod values, so the runtime mock server and validators consume API directly — while the type layer derives everything else. Derivations: type Route = keyof typeof API (literal union of endpoints — typos in client calls die at compile time). Split method/path per route with a template pattern: R extends `${infer M} ${infer P}` (section 5). Params compute from the path via the recursive PathParams<P> machine — "GET /users/:id" yields { id: string }. Response/body types come from the schemas by z.infer (the ecosystem's infer at work): type ResponseOf<R extends Route> = z.infer<typeof API[R]["response"]> — indexed access (3.7.4) into the const map. The client's entire surface is then one generic function: call<R extends Route>(route: R, opts: { params: PathParams<PathOf<R>> } & BodyOf<R>): Promise<ResponseOf<R>> — call sites get autocompleted routes, exact params, exact bodies, exact response types, and every drift class dies: endpoint list is the map; interfaces are schema-derived; the mock server iterates the same map (Object.entries(API)) serving schema-generated fixtures.

Where to stop: don't type-level-parse query strings or header grammars (marginal payoff, error-message and check-time cost — validate at runtime instead); don't chase perfect HTTP semantics in types (status-code unions per route: only if consumers actually branch on them); name every intermediate (PathOf, BodyOf, ResponseOf) so downstream errors read as English and the checker memoizes (section 6); and put a @ts-expect-error fixture file in CI asserting that wrong params/bodies fail — type machinery gets tests like any machinery. Payoff test satisfied: three hand-maintained artifacts become one map plus ~30 lines of types that hundreds of call sites lean on.