- 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
You hover over a function and the tooltip reads
Promise<{ id: string; name: string }>. You want the
{ id: string; name: string } part on its own, as a named
type, without typing it out by hand. The shape lives inside
another type. You did not write it. A library did, or an
async function inferred it. You just want to read it back
out.
That is the whole job of infer. It is the keyword that lets
you point at a position inside a type and say: whatever sits
here, capture it and give it a name. To get there you need two
things first — constraints with extends, and conditional
types. Each one is small. Together they let you treat types as
data you can take apart.
extends is two different operators
This trips up everyone once. The extends keyword does two
unrelated jobs depending on where it sits.
In a generic parameter list, extends is a constraint. It
bounds what a type parameter is allowed to be.
function firstKey<T extends object>(obj: T): keyof T {
return Object.keys(obj)[0] as keyof T;
}
firstKey({ a: 1, b: 2 }); // keyof T is "a" | "b"
firstKey("nope"); // error: string is not object
T extends object reads as "T must be assignable to
object." It does not mean inheritance. It means: the caller
can pass anything that fits this bound, and inside the function
you can rely on every member of that bound being present.
In a type expression, extends is a question. It asks "is the
left side assignable to the right side?" and branches on the
answer. That second form is the conditional type, and it is
where the work happens.
Conditional types branch on a test
A conditional type has the shape
A extends B ? X : Y. If A is assignable to B, the type
resolves to X. Otherwise it resolves to Y.
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
On its own that is a yes/no test. The interesting version
distributes over a union. When the thing on the left of
extends is a naked type parameter and you hand it a union,
TypeScript applies the conditional to each member separately,
then unions the results.
type NonNullableX<T> = T extends null | undefined
? never
: T;
type C = NonNullableX<string | null | number>;
// string | number
Each member of string | null | number runs through the
test. null resolves to never, which drops out of a union.
The rest survive. That is exactly how the built-in
NonNullable<T> works.
infer captures a type from a position
Conditional types get sharp once you add infer. Inside the
extends clause of a conditional type, infer X declares a
new type variable and fills it with whatever TypeScript matches
at that spot.
Here is the Promise case from the opening.
type Awaited2<T> = T extends Promise<infer R> ? R : T;
type D = Awaited2<Promise<number>>; // number
type E = Awaited2<string>; // string
Read T extends Promise<infer R> as: "if T is a Promise of
something, call that something R." When T is
Promise<number>, R binds to number and the conditional
returns it. When T is not a Promise, the test fails and you
get T unchanged.
The array element case is the same move at a different
position.
type ElementOf<T> = T extends (infer U)[] ? U : never;
type F = ElementOf<string[]>; // string
type G = ElementOf<[1, 2, 3]>; // 1 | 2 | 3
type H = ElementOf<number>; // never
(infer U)[] matches any array and binds U to the element
type. A tuple is also an array, so [1, 2, 3] matches and U
becomes the union of its literal members.
You can capture more than one position in a single conditional.
Pulling apart a function type is the standard example.
type ReturnTypeOf<T> =
T extends (...args: any[]) => infer R ? R : never;
type Params<T> =
T extends (...args: infer A) => any ? A : never;
function makeUser(id: string, age: number) {
return { id, age };
}
type R = ReturnTypeOf<typeof makeUser>;
// { id: string; age: number }
type P = Params<typeof makeUser>;
// [id: string, age: number]
R captures the return position. A captures the whole
parameter list as a tuple, labels included. These two are the
built-in ReturnType and Parameters utility types, written
out so you can see there is no magic underneath.
Recursing into nested shapes
infer matches one level at a time, but a conditional type can
call itself. That is how you reach into a value that is wrapped
more than once — a Promise<Promise<T>>, or an array of arrays.
type DeepAwait<T> =
T extends Promise<infer R> ? DeepAwait<R> : T;
type J = DeepAwait<Promise<Promise<string>>>;
// string
Each pass peels one Promise layer and feeds the inside back
through the same type. The recursion stops when the test fails,
which is when there is nothing left to unwrap. The standard
library Awaited<T> does this for you, including the
thenable edge cases. The point here is that the mechanism is
something you can read and rebuild, not a black box.
A small DSL: typed route parameters
Constraints, conditional types, and infer pay off when you
build a tiny type-level language over your own strings. A
common one: given a route pattern like "/users/:id/posts/:slug",
produce an object type with id and slug as keys.
Template literal types let you match against the shape of a
string and pull pieces out with infer.
type RouteParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? { [K in Param | keyof RouteParams<`/${Rest}`>]: string }
: T extends `${string}:${infer Last}`
? { [K in Last]: string }
: {};
type Params1 = RouteParams<"/users/:id/posts/:slug">;
// { id: string; slug: string }
type Params2 = RouteParams<"/health">;
// {}
The first branch matches a parameter that is followed by more
path, captures the parameter name in Param and the tail in
Rest, then recurses on the tail. The second branch matches a
trailing parameter with nothing after it. The third handles a
path with no parameters at all. Three cases, and the type now
reads route strings the way a runtime parser would.
Wire it to a function and the call site checks itself.
function route<T extends string>(
pattern: T,
handler: (params: RouteParams<T>) => void,
): void {
// register pattern -> handler
}
route("/users/:id/posts/:slug", (params) => {
params.id; // string
params.slug; // string
params.bogus; // error: no such property
});
The T extends string constraint is what lets T stay as the
literal "/users/:id/posts/:slug" instead of widening to
string. Without the constraint the template literal match has
nothing to bite on. With it, the handler argument is typed from
the pattern alone. Nobody wrote { id: string; slug: string }
by hand. The compiler read it out of the route.
Where this earns its keep
Most days you will reach for the built-ins — Awaited,
ReturnType, Parameters, NonNullable. Knowing how they are
built changes how you read a confusing error. When a tooltip
shows a type you do not recognize, it is usually one of these
conditionals resolving against an input you did not expect, and
now you can trace it.
The DSL case is where you go past the built-ins: validating
config keys, deriving event-handler maps from a string union,
typing a query builder so a bad column name fails at compile
time. Each of those is the same three pieces. A constraint to
keep the input narrow, a conditional to test its shape, and
infer to lift a piece of it back out.
Start with the Promise and array unwrappers above. Once those
read as obvious, the function and route examples are the same
idea pointed at a different position.
If this was useful
Conditional types and infer are the spine of The TypeScript
Type System, the second book in The TypeScript Library — it
goes from these unwrappers through mapped types, template
literal types, and the branded-type and DSL patterns that turn
a string into a checked grammar. If the route example clicked
and you want to build the rest of that machinery on purpose,
that is the book.
The TypeScript Library — the 5-book collection. Books 1 and 2 are the core path; 3 and 4 substitute for 1 and 2 if you come from the JVM or PHP; book 5 is for anyone shipping TS at work.
- TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling across Node, Bun, Deno, and the browser.
- The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types.
- Kotlin and Java to TypeScript — variance, null safety, sealed classes to unions, coroutines to async/await.
- PHP to TypeScript — the sync-to-async shift, generics, discriminated unions for PHP 8+ developers.
- TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.
All five books ship in ebook, paperback, and hardcover.

Top comments (0)