- 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 open a service file in someone else's repo and see this near the top:
import { api } from "./client";
type CreateUserArgs = Parameters<typeof api.users.create>[0];
type CreateUserResult = Awaited<ReturnType<typeof api.users.create>>;
async function onSubmit(form: CreateUserArgs): Promise<CreateUserResult> {
return api.users.create(form);
}
No interface. No DTO file. No generated types from a schema. The function pulled its argument shape and its resolved return type straight out of api.users.create (whatever that function is). If api.users.create changes signature, onSubmit either updates with it or fails to compile. The contract is the function. The types are derivative.
Your first reaction is probably the right one. Wait, how does that even work.
It works because of a single keyword: infer. Parameters, ReturnType, and Awaited are not magic compiler primitives. They are three lines of TypeScript each, in lib.es5.d.ts, written in terms of conditional types and the infer keyword. Once you can read those three, you can write your own. The compiler will pull anything out of anything for you, as long as you can describe the shape it lives in.
This post is the eight patterns I reach for the most. The first three you have probably used. The last five are where the wizard reputation comes from.
Pattern 1 — Parameters<T>: argument extraction
The simplest reverse-lookup TypeScript ships with. Given a function type, give me back its arguments as a tuple.
type Parameters<T extends (...args: any) => any> =
T extends (...args: infer P) => any ? P : never;
Three things are happening. T is constrained to a function type so the conditional always has a function shape to destructure. The conditional T extends (...args: infer P) => any says "if T looks like a function, capture its argument list as P." If it does, you get P; if it does not, never.
P is a tuple, not a single value. (a: string, b: number) => void produces [a: string, b: number]. That tuple form is what makes Parameters<T>[0] work to grab the first argument, and what makes ...args: Parameters<typeof fn> work to forward arguments to a wrapper.
You reach for it whenever you want a function's arg shape without naming it twice. Wrapping a third-party SDK call, building a typed event bus, writing a withLogging(fn) higher-order function. Same pattern every time.
Pattern 2 — ReturnType<T>: the other side of the function
The mirror image. Capture what comes out instead of what goes in.
type ReturnType<T extends (...args: any) => any> =
T extends (...args: any) => infer R ? R : never;
Same conditional shape, infer moved from the argument position to the return position. The any in (...args: any) is intentional: you do not care what the arguments are, you only care about the return.
Use it when the return value of a function is the actual thing you want a name for: a result type, a config object, or a builder's output. The point is not to avoid typing. That is what an explicit interface is for. The point is when the return type is computed: a function that returns { status: "ok"; data: T } | { status: "err"; error: E } for some T and E derived from its inputs. Writing that union by hand goes stale; ReturnType does not.
A small wrinkle: for overloaded functions, ReturnType only sees the last overload signature. Worth flagging now because it bites later (Pattern 6).
Pattern 3 — Awaited<T>: the unwrap that recurses
Promise<User> is fine until you get Promise<Promise<User>> from chained async work and ReturnType hands you a Promise<...> you have to peel.
type Awaited<T> =
T extends null | undefined ? T :
T extends object & { then(onfulfilled: infer F, ...args: infer _): any } ?
F extends ((value: infer V, ...args: infer _) => any) ?
Awaited<V> :
never :
T;
Reproduced from TypeScript's lib.es5.d.ts (Apache-2.0, Microsoft).
This is the version TypeScript 4.5 shipped, and it is structural. It does not check for instanceof Promise. It checks for then with a callback whose first argument is the resolved value, then recurses on that value. PromiseLike, native Promise, and custom thenables: all the same to Awaited.
The recursion is the point. Awaited<Promise<Promise<Promise<User>>>> is User. That makes Awaited<ReturnType<typeof asyncFn>> reliable as a "what does this resolve to" extractor regardless of how many promise wrappers stacked up.
Three patterns down. None of them are magic. They all use the same shape: a conditional type with an infer clause that names a position in the type structure. From here on, the patterns get more interesting because the structure being matched gets richer.
Pattern 4 — Head<T>: first element of a tuple
infer works on tuple types the same way it works on function signatures. Tuples destructure with the rest spread.
type Head<T extends readonly unknown[]> =
T extends readonly [infer F, ...unknown[]] ? F : never;
type A = Head<[string, number, boolean]>; // string
type B = Head<[]>; // never
type C = Head<readonly ["a", "b"]>; // "a"
The pattern [infer F, ...unknown[]] binds the first element to F and ignores the rest. readonly on the constraint matters because as const arrays are readonly tuples; without it, Head<readonly [...]> would not match.
This shows up in form-builder libraries that thread a tuple of field types through their API, in routers that destructure path segments, and in any DSL where the first element of a tuple has a different role than the rest (a discriminator, a method name, a header).
The pair you want next is Tail: give me everything except the first element.
type Tail<T extends readonly unknown[]> =
T extends readonly [unknown, ...infer R] ? R : [];
type X = Tail<[1, 2, 3]>; // [2, 3]
Together, Head and Tail let you walk a tuple recursively at the type level. Which is exactly what the next pattern needs.
Pattern 5 — Last<T>: recursive last via infer Last
The last element of a tuple is harder than the first because tuples grow on the right and TypeScript reads them left-to-right. The way you do it is recurse.
type Last<T extends readonly unknown[]> =
T extends readonly [...unknown[], infer L] ? L : never;
type A = Last<[1, 2, 3]>; // 3
type B = Last<["a", "b", "c"]>; // "c"
type C = Last<[]>; // never
TypeScript 4.0 and later support the variadic tuple form [...unknown[], infer L] directly, so you do not need to recurse manually for this one. The compiler handles the recursion under the hood.
For older versions or for cases where you need to walk every element (not just grab the last), the manual recursion shape is worth knowing:
type LastRec<T extends readonly unknown[]> =
T extends readonly [infer _Head, ...infer Rest]
? Rest extends []
? _Head
: LastRec<Rest>
: never;
Each step strips the head and checks if the tail is empty. If it is, the head is the last. Otherwise recurse on the tail. This is the building block for type-level functions that need to fold over a tuple: building a discriminated union from a tuple of literal types, computing a final state from a sequence of transitions, walking a path tuple to the leaf type of a deeply-nested object.
The recursion limit is real, and the exact ceiling depends on the recursion shape. Non-tail-recursive aliases bottom out much earlier (around 50 levels in the compiler's internal instantiationDepth cap); tail-recursive aliases (the LastRec shape above, which the compiler can detect and unroll iteratively) get into the ~1000 range. See the TypeScript checker.ts constants and the TS 4.5 tail-recursion-elimination notes. For the lengths you see in practice (route segments, function arguments, builder steps), you are nowhere near either limit.
Pattern 6 — Overload extraction: the gotcha that bites everyone
Here is the one that has cost more debugging hours than it should. Function overloads.
function api(input: string): string;
function api(input: number): number;
function api(input: string | number): string | number {
return input;
}
type R = ReturnType<typeof api>; // number
type P = Parameters<typeof api>; // [input: number]
You expected string | number for both. You got the second overload. Why.
Because infer on an overloaded function only sees the last call signature. The earlier overloads are invisible to the conditional type. This is documented in the TypeScript handbook section on conditional types, and it is the single most common "but I thought infer was magic" disappointment.
The workaround when you control the function is to merge overloads into a single signature with a union argument type. When you do not control it (a third-party library with three or four overloads), there is a manual extraction trick that walks all the overloads:
type OverloadedReturn<T> =
T extends {
(...args: any[]): infer R1;
(...args: any[]): infer R2;
(...args: any[]): infer R3;
(...args: any[]): infer R4;
} ? R1 | R2 | R3 | R4 :
T extends {
(...args: any[]): infer R1;
(...args: any[]): infer R2;
} ? R1 | R2 :
T extends (...args: any[]) => infer R ? R : never;
type R2 = OverloadedReturn<typeof api>; // string | number
The trick is to ask the type system to match a multi-call-signature shape and capture each return separately. It is verbose, it caps at whatever number of overloads you wrote, and it fails quietly if the function has more overloads than you matched. It is also the only way without a code change.
The wizard move is knowing this gotcha exists before you write the type and design around it. Single-overload signatures with union arguments are easier to consume from generic code than multi-overload signatures, even when the original author would have written four signatures.
Pattern 7 — Template-literal capture: infer inside strings
This is the one that surprises people. infer works on template-literal types.
type ExtractParam<S extends string> =
S extends `/${infer Path}` ? Path : never;
type A = ExtractParam<"/users">; // "users"
type B = ExtractParam<"/posts/123">; // "posts/123"
type C = ExtractParam<"users">; // never (no leading /)
The string S is matched against the literal pattern `/${infer Path}`: leading slash, then capture the rest as Path. If the input does not start with /, the conditional fails and you get never.
This is what powers type-safe routers. Take the next step and pull out parameter names from a route pattern:
type RouteParams<S extends string> =
S extends `${string}:${infer Param}/${infer Rest}`
? Param | RouteParams<`/${Rest}`>
: S extends `${string}:${infer Param}`
? Param
: never;
type P = RouteParams<"/users/:id/posts/:postId">; // "id" | "postId"
The recursion does the heavy lifting. Match :Param/Rest, union the param with the recursive result on the remainder, fall through to the single-param case, terminate with never. The function signature for req.params then becomes { [K in RouteParams<typeof route>]: string } and your route handler knows the names of its params at compile time.
Same trick parses CSS class strings, pulls the version out of ^1.2.3, splits a SQL identifier on the dot, extracts a tag name from <div>.... Anywhere the structure of a string is part of your API, template-literal infer is how you make TypeScript see it.
This pattern is also what tRPC, Hono, and the new wave of type-safe routers use under the hood. When their types feel like the framework "knows" your URLs, this is what is happening.
Pattern 8 — infer inside conditional branches
The last pattern is the one that ties the rest together. infer is not limited to the position you check against. It can sit inside a deeper conditional and capture a type only on the branch where the outer match succeeded.
type UnwrapResult<T> =
T extends { ok: true; value: infer V } ? V :
T extends { ok: false; error: infer E } ? E :
never;
type R1 = UnwrapResult<{ ok: true; value: User }>; // User
type R2 = UnwrapResult<{ ok: false; error: ApiError }>; // ApiError
Each branch of the conditional has its own infer binding, scoped to that branch. The compiler walks the union, picks the matching branch, and the infer on the winning branch resolves to the captured type. The losing branches are dropped.
You can nest infer inside object types, array types, function types, all on the same line:
type ArgsAndReturn<T> =
T extends (...args: infer A) => Promise<infer R>
? { args: A; resolved: R }
: T extends (...args: infer A) => infer R
? { args: A; resolved: R }
: never;
type X = ArgsAndReturn<(id: string) => Promise<User>>;
// { args: [id: string]; resolved: User }
Two infer clauses in one signature, picking up the argument tuple and the resolved value in a single match. This is how libraries like zod and valibot build inference from a schema object: a single conditional with a few infer clauses extracts every type-level fact they need.
The mental model that makes all of this click: infer is a question you ask the type checker. "If you can match this shape, tell me what was in this slot." The slot can be an argument, a return, a tuple element, a string fragment, an object property, the resolved value of a thenable, the error type of a result. Anywhere the type system can read a type out of a structure, infer is the keyword that lets you bind it.
The next time you find yourself typing out a shape that mirrors something the codebase already has, stop. There is probably a one-line infer that would extract it. The version that uses infer is the version that does not break the next time someone changes the source of truth. That is the wizard move. The trick is just one keyword.
If this was useful
infer is one chapter in The TypeScript Type System, sitting right between conditional types and the template-literal machinery that powers type-safe routers. The book builds from generics through mapped and conditional types into the DSL-level patterns that libraries like zod, ts-pattern, and Effect rely on. If the eight patterns above made you want to write your own Awaited, that is the book.
If you are coming from JVM languages, the function-type machinery in TypeScript covers the same ground that variance and reified generics do for you in Kotlin. Kotlin and Java to TypeScript makes that bridge. If you are coming from PHP 8+, PHP to TypeScript covers generics and inference from the other side. If you are shipping TS at work, TypeScript in Production covers the build, monorepo, and dual-publish concerns the type system itself does not touch.
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)