01 · Advanced Type-Level Programming¶
TypeScript's type system is Turing-complete-ish in practice: recursive
conditional types, template literal types, and tuple manipulation let
you compute types, not just describe them. This module builds a
dot-path type ("server.port") that autocompletes and type-checks
against a real object shape, plus the smaller building blocks it rests
on.
Template literal types¶
type EventName<T extends string> = `on${Capitalize<T>}`;
type ClickEvent = EventName<"click">; // "onClick"
Template literal types build new string-literal types out of others —
Capitalize is one of TypeScript's four built-in intrinsic string
manipulation types (Uppercase, Lowercase, Capitalize,
Uncapitalize), all resolved entirely at compile time.
Recursive conditional types: splitting a string¶
type Split<S extends string, D extends string> =
S extends `${infer Head}${D}${infer Rest}`
? [Head, ...Split<Rest, D>]
: [S];
type Parts = Split<"a.b.c", ".">; // ["a", "b", "c"]
infer inside a conditional type's extends clause captures a piece of
the matched string as its own type variable. Split recurses on Rest
until the pattern no longer matches, at which point the base case [S]
wraps whatever's left — the same head-recursion shape as splitting a
string at runtime, just operating on types instead of values.
Building a type-safe dot-path¶
type Paths<T> = T extends object
? {
[K in keyof T & string]: T[K] extends object
? K | `${K}.${Paths<T[K]>}`
: K;
}[keyof T & string]
: never;
interface Config {
server: { port: number; host: string };
db: { url: string };
}
type ConfigPath = Paths<Config>;
// "server" | "server.port" | "server.host" | "db" | "db.url"
function getPath<T, P extends Paths<T>>(obj: T, path: P): unknown {
const parts = (path as string).split(".");
let current: unknown = obj;
for (const part of parts) {
current = (current as Record<string, unknown>)[part];
}
return current;
}
const config: Config = {
server: { port: 8080, host: "localhost" },
db: { url: "postgres://" },
};
console.log(getPath(config, "server.port"));
console.log(getPath(config, "db.url"));
getPath(config, "server.pot") (a typo) is a compile error, not a
runtime undefined — Paths<Config> is a closed union of every valid
dot-path through Config, computed once from the interface, so a typo
falls outside the union and TypeScript rejects it before the code ever
runs. This is the mechanism behind libraries like Zod's
.path() helpers and typed form libraries' field-path props.
Tuple-based type-level arithmetic¶
type BuildTuple<L extends number, T extends unknown[] = []> = T["length"] extends L
? T
: BuildTuple<L, [...T, unknown]>;
type Add<A extends number, B extends number> = [...BuildTuple<A>, ...BuildTuple<B>]["length"];
type Sum = Add<3, 4>; // 7
const sum: Sum = 7;
console.log(sum);
There's no arithmetic operator on numeric types in TypeScript — Add
"computes" 3+4 by building a 3-element tuple and a 4-element tuple out
of unknown, concatenating them, and reading off the resulting tuple's
length. This works but doesn't scale: it's O(n) in tuple length, and
large numbers blow the recursion depth limit (see the trap below).
Exhaustiveness checking with never¶
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${JSON.stringify(x)}`);
}
type Shape = { kind: "circle"; r: number } | { kind: "square"; s: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.r ** 2;
case "square":
return shape.s ** 2;
default:
return assertNever(shape);
}
}
console.log(area({ kind: "circle", r: 2 }).toFixed(2));
Inside default, shape's type has been narrowed to never — every
member of the Shape union was handled by an earlier case. If someone
adds a third shape kind later and forgets to add its case, shape in
the default branch is no longer never (it's the new, unhandled
variant), and passing it to assertNever(x: never) becomes a compile
error at exactly the spot the new case was missed.
Traps¶
Deep recursion hits TS2589. Add<500, 500> (or a Paths<T> over
a very deeply nested object) can exceed TypeScript's recursion depth
limit: Type instantiation is excessively deep and possibly infinite.
Tuple-based arithmetic in particular is unusable much past small numbers
— it exists here to teach the mechanism, not as production advice for
real math.
Paths<T> recurses into arrays and functions too, since they're
also object — without excluding them (T[K] extends object ? ...
needs a check like T[K] extends readonly unknown[] ? K : ...), a
string[] property produces nonsensical numeric-index paths. Real
implementations (as in lodash's type-level get helpers) special-case
arrays and functions explicitly.
infer can only appear inside the extends clause of a conditional
type, not in an arbitrary position — type X<S> = S extends infer T ? T : never
is valid, but trying to use infer outside a conditional's extends
position is a syntax error.
How It Actually Works¶
Template literal types make the checker treat string types compositionally the way it treats object shapes: `on${Capitalize<Event>}` is resolved by taking each concrete member of the Event union, applying the intrinsic Capitalize<S> transformation (one of a handful of string-manipulation intrinsics — Uppercase, Lowercase, Capitalize, Uncapitalize — implemented directly in the compiler rather than as ordinary conditional types, because they need to inspect and rewrite individual characters of a string-literal type, something conditional/mapped types alone can't express), and concatenating the literal template pieces — the whole thing distributing over unions the same way conditional types do, producing a new union of concrete string-literal types, not a general string.
Recursive type-level programming (parsing a string-literal type character by character with infer, computing a tuple's length via mapped-type accumulation, and so on) is genuine, if unusual, computation performed entirely by the type checker during the check phase — it has no runtime cost whatsoever since none of it survives erasure, but it has a real compile-time cost, bounded by the compiler's hard-coded instantiation-depth limit (currently in the high hundreds), which exists because unrestricted recursive conditional types make type checking undecidable in the general case; hit the limit and the checker gives up and reports the type as any or an explicit depth-exceeded error rather than looping forever.
Mapped types with key remapping ({ [K in keyof T as NewKey]: T[K] }, using an as clause inside the mapped-type position) let you transform not just the values but the property names themselves during the iteration over keyof T — this is the exact mechanism event-emitter and getter/setter-generating utility types use to derive "onFoo" handler property names from a base "foo" event-name union, all computed once at the type level with zero corresponding runtime code generated for it.
Cheat sheet¶
| Feature | What it does |
|---|---|
`${A}${B}` (template literal type) |
Concatenates string-literal types |
Capitalize<T> / Uppercase / Lowercase / Uncapitalize |
Built-in string-literal transforms |
S extends \${infer Head}${D}${infer Rest}`` |
Captures parts of a matched string type |
T extends unknown[] ? T["length"] : never |
Reads a tuple's length as a numeric literal type |
function assertNever(x: never): never |
Compile-time exhaustiveness check |
Exercise¶
Extend Paths<T> with a companion PathValue<T, P extends Paths<T>>
type that resolves to the value type at that path (e.g.
PathValue<Config, "server.port"> should be number, not unknown).
Rewrite getPath to use it as its return type instead of unknown, and
confirm getPath(config, "server.port") now has inferred type number
by assigning its result to a const port: number = ... without a cast.