- Book: The TypeScript Type System — From Generics to DSL-Level Types
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
The handbook teaches infer with two examples. Awaited<T> peels a Promise. ReturnType<T> peels a function. Both are linear: one extends, one infer, one shape out. Real codebases ask infer to walk tuples or parse strings, and the working versions usually live in Stack Overflow answers older than the codebase you paste them into.
Five recipes that go past the obvious. Each one starts with a real failure mode and ends with a lesson about how the type system handles distributivity, variance, and recursion depth.
Recipe 1: Last element of a tuple
The wrong version looks right at first glance.
type Last<T extends readonly unknown[]> =
T extends readonly [...unknown[], infer L] ? L : never;
type A = Last<[1, 2, 3]>; // 3
type B = Last<[string, number]>; // number
type C = Last<[]>; // never
That works. The problem is the version that fails silently while looking plausible:
type LastBroken<T extends readonly unknown[]> =
T extends readonly (infer Head)[] ? Head : never;
type X = LastBroken<[1, 2, 3]>; // 1 | 2 | 3
The (infer Head)[] pattern collapses a tuple into a union of its element types, because that is what an array type is at the value level. The fixed-width pattern [...unknown[], infer L] is what tells the compiler you are matching a tuple with a known shape, where the rest can be anything but the last slot is the one you want named.
infer matches positions. The position is the syntactic shape of the type expression. [...unknown[], infer L] asks for a tuple whose shape is "any number of elements followed by exactly one more". (infer Head)[] asks for an array whose element type unifies into one name, and unification across positions is a union.
The same trick gives you First:
type First<T extends readonly unknown[]> =
T extends readonly [infer F, ...unknown[]] ? F : never;
type D = First<[1, 2, 3]>; // 1
And once you have First and Last, you have the head-tail split, which is recipe 2.
Recipe 2: Head and tail, used recursively
Splitting a tuple into its first element and the rest is the primitive every tuple-walking type is built from.
type Head<T extends readonly unknown[]> =
T extends readonly [infer H, ...unknown[]] ? H : never;
type Tail<T extends readonly unknown[]> =
T extends readonly [unknown, ...infer R] ? R : [];
Tail returns [] on an empty tuple instead of never because most recursive consumers want a base case that lets them stop walking, not a dead branch. The convention matches how the standard library handles it.
The reason head-tail matters is that you can now write a type that does something to every position of a tuple:
type Reverse<T extends readonly unknown[]> =
T extends readonly [infer H, ...infer R]
? [...Reverse<R>, H]
: [];
type R1 = Reverse<[1, 2, 3]>; // [3, 2, 1]
The trap is recursion depth. TypeScript caps recursive type instantiation at a version-dependent limit in the high hundreds. A Reverse that walks a 50-element tuple is fine. A Reverse of a tuple computed from keyof of a wide union is the kind of expression that hits "Type instantiation is excessively deep and possibly infinite" without warning.
The fix at the source is to keep tuple-recursive types out of paths the user can pass arbitrary input to. The fix at the consumer is to assert the tuple shape with a manual annotation when the inferred one runs out:
const r = reverse([1, 2, 3] as const);
// ^? readonly [3, 2, 1]
The as const narrows the runtime array to its tuple type, which is what the recursive Reverse needs to terminate at a knowable depth.
The deeper point: recursive infer is expressive enough to encode algorithms, and the compiler runs them on a typecheck-time budget. Keep that budget visible. Tagged-template-literal SQL libraries like Kysely use the same recursive-walk shape, terminating because the input string is statically bounded.
Recipe 3: Extracting query params from a URL template literal
This is the recipe that makes typed routers work.
type ExtractParams<S extends string> =
S extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractParams<`/${Rest}`>
: S extends `${string}:${infer Param}`
? Param
: never;
type P1 = ExtractParams<"/users/:id">; // "id"
type P2 = ExtractParams<"/users/:id/posts/:pid">; // "id" | "pid"
type P3 = ExtractParams<"/static">; // never
Two infer slots in one conditional. The first matches the param name up to the next slash. The second matches the rest of the string so the recursive call can chew through it. The base case is a path with one trailing param and no slash after it; that branch matches ${string}:${infer Param} and returns just that name.
The version that does not work is the one without the leading /${Rest} reconstruction:
type Broken<S extends string> =
S extends `${string}:${infer P}/${infer Rest}`
? P | Broken<Rest>
: S extends `${string}:${infer P}`
? P
: never;
type Q = Broken<"/users/:id/posts/:pid">;
// ^? "id" — :pid is lost because Rest is "posts/:pid" and the
// next match needs a leading separator to anchor the param
Template-literal pattern matching is greedy from the left and matches against the whole input. Without the leading /, Rest starts at posts/:pid, which still has the :pid you want. But the outer ${string}: consumes from the start of the string up to the first colon, and posts has no colon ahead of it that the pattern can anchor on the way the original input did. The reconstruction restores the anchor.
Once you have parameter extraction, the typed handler signature falls out:
type Params<S extends string> = {
[K in ExtractParams<S>]: string;
};
function get<S extends string>(
path: S,
handler: (req: { params: Params<S> }) => void,
): void {
// runtime
}
get("/users/:id/posts/:pid", (req) => {
req.params.id; // string
req.params.pid; // string
// @ts-expect-error
req.params.foo;
});
That is the same shape used by libraries like Hono and the typed parts of Next.js's app router. The recipe is a regular language: string parsing in the type system, expressed as nested infer.
Recipe 4: Constructor parameters that respect overloaded constructors
The handbook's ConstructorParameters<T> is Parameters<T> for the new signature. Same trap as Parameters on overloads: it returns the last overload only.
class Connection {
constructor(url: string);
constructor(host: string, port: number);
constructor(host: string, port: number, opts: { tls: boolean });
constructor(
public readonly host: string,
public readonly port?: number,
public readonly opts?: { tls: boolean },
) {}
}
type Args = ConstructorParameters<typeof Connection>;
// ^? [host: string, port?: number, opts?: { tls: boolean }]
That is the implementation signature, not any of the three documented overloads. A wrapper built on it accepts every shape, valid or not.
The infer-based fix walks the overload tuple by hand:
type CtorOverloads<T> =
T extends {
new (...args: infer A1): infer R1;
new (...args: infer A2): infer R2;
new (...args: infer A3): infer R3;
}
? A1 | A2 | A3
: T extends {
new (...args: infer A1): infer R1;
new (...args: infer A2): infer R2;
}
? A1 | A2
: T extends new (...args: infer A) => unknown
? A
: never;
type AllArgs = CtorOverloads<typeof Connection>;
// ^? [string] | [string, number] | [string, number, { tls: boolean }]
The union of tuples is the shape a wrapper needs to forward through:
function trace(...args: AllArgs): Connection {
console.log("new Connection", args);
return new Connection(...(args as [string]));
}
trace("redis://localhost");
trace("localhost", 6379);
trace("localhost", 6379, { tls: true });
// @ts-expect-error
trace("localhost", "wrong");
The cast at the call site is the unavoidable cost. TypeScript's new (...args: A1) => R1 does not let you spread a union of tuples into a constructor signature in a way the checker accepts, even though every member of the union is one of the original overloads. The pragmatic answer is the same one that applies to Parameters and ReturnType: re-declare the wrapper's overloads when you can. The infer-based extractor is for the case where the class is third-party and you need to know what the call shapes actually were.
Overload-aware extraction is a fixed-arity operation. You write the helper at the depth your codebase uses (three is enough for most APIs) and you accept that adding a fourth overload to the source class will silently drop to the catch-all branch.
Recipe 5: Resolving a chain of promises
Awaited<T> peels one Promise. The handbook's recursive definition is enough for the common case. The trap is when the chain is built from .then callbacks and the type system has to walk the chain through nested function returns:
declare function fetchUser(id: string): Promise<{
id: string;
posts: Promise<Array<{
id: string;
comments: Promise<Array<{ id: string; body: string }>>;
}>>;
}>;
That shape is contrived but the GraphQL-resolver case it stands in for is not. Every relation field is a thunk that returns a promise. A flattened view of the leaf type, what you would get if you fully resolved every nested promise, needs a recursive Awaited that knows how to go through arrays:
type DeepAwaited<T> =
T extends Promise<infer U>
? DeepAwaited<U>
: T extends Array<infer E>
? Array<DeepAwaited<E>>
: T extends object
? { [K in keyof T]: DeepAwaited<T[K]> }
: T;
type Flat = DeepAwaited<ReturnType<typeof fetchUser>>;
// ^? {
// id: string;
// posts: Array<{
// id: string;
// comments: Array<{ id: string; body: string }>;
// }>;
// }
Three branches do the work. The promise branch peels and recurses. The array branch keeps the array wrapper and recurses on the element type, so Promise<number>[] becomes number[]. The object branch maps over keys and recurses on each value, so a relation graph stays a relation graph with the promises stripped out. Primitives bottom out untouched.
The trap is the same recursion-depth cap as recipe 2. A DeepAwaited over a generated type from a tRPC router or an ORM relation graph can hit "Type instantiation is excessively deep" on a deep enough relation. The fix is to keep DeepAwaited out of paths the user controls.
A function-typed property is the question this recipe does not answer. None of the three branches match a function shape, so it is left alone. That is usually what you want; flattening through a function would change its signature, which is not what "resolve the promise chain" means.
What ties the five together
Three rules survive across the recipes.
infer matches positions, and the position is the syntactic shape of the type expression. [...unknown[], infer L] is a different match than (infer L)[] because one is a fixed-shape tuple and the other is a homogeneous array. Read the pattern the way the compiler does.
Recursive infer is an algorithm running at typecheck time, on a budget. The tuple recursion limit and the related "Type instantiation is excessively deep" error are the compiler telling you the budget is up. Keep recursive-type inputs statically bounded.
Conditional types distribute over union types when the checked type is a naked type parameter. T extends Promise<infer U> ? U : never applied to Promise<A> | Promise<B> returns A | B, not never. That is the rule that makes the URL-param recipe work: the | between recursive calls is what gives you "id" | "pid" instead of "pid" alone.
The handbook teaches infer with Awaited and ReturnType because those are the simplest cases. The five recipes here are the next layer up, and they are the primitives behind the typed routers and typed query builders you already use. If your codebase has a // @ts-ignore on a complicated conditional type, audit it: the bug is probably one of these five with a typo in the position match.
If this was useful
The five recipes above are the entry-level versions of the conditional-type and infer patterns The TypeScript Type System spends a chapter on each. The book takes the same posture this post does: infer is a primitive for matching positions inside a type expression, and the senior-grade move is reading that expression the way the compiler does. The chapter on tuple-recursive types covers the head-tail walker generalised to depth-N. The chapter on template-literal types extends the URL-param extractor into a full router-shape inference helper.
If you are coming to the type system from runtime-first TypeScript, TypeScript Essentials is the book that pairs with this one: the runtime feature set, the narrowing rules, and the build configurations across Node, Bun, Deno, and the browser. TypeScript in Production picks up where both end, with the library-authoring decisions that make these conditional types stable across versions of the function they came from.
The five-book set:
- TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point: amazon.com/dp/B0GZB7QRW3
- The TypeScript Type System — From Generics to DSL-Level Types — deep dive: amazon.com/dp/B0GZB86QYW
- Kotlin and Java to TypeScript — A Bridge for JVM Developers — bridge for JVM devs: amazon.com/dp/B0GZB2333H
- PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — bridge for PHP devs: amazon.com/dp/B0GZBD5HMF
- TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — production layer: amazon.com/dp/B0GZB7F471
All five books ship in ebook, paperback, and hardcover.

Top comments (0)