Conditional Types and infer Without Nested Nightmares
Read a conditional type as an if for types, see how it spreads over a union and use infer to take a piece out of a type. After this, you can name each step of a nested type, test it and stop copying type puzzles you cannot read.
Course content
Sign in to save your progress.
-
Types That Compute
-
Conditional Types and infer Without Nested Nightmares
-
Template Literal Types for Routes and Event Names
-
-
-
Brands and Boundaries
-
Branded Types So IDs Don't Mix
-
satisfies and const Assertions When Inference Isn't Enough
-
-
-
Declare What JavaScript Didn't
-
Writing .d.ts for a Plain JS Module
-
Ambient Modules and Global Augmentation Without Breaking Everything
-
-
-
Projects That Scale
-
Project References and Solution-Style tsconfig
-
typescript-eslint Rules That Catch Real Mistakes
-
-
-
Migrate and Keep Moving
-
Move a JavaScript Folder to TypeScript Without a Big Bang
-
allowJs, checkJs and Leaving JSDoc Behind
-
Keep Build Times Sane: incremental and What Actually Slows tsc
-
-
- Final Check: TypeScript Advanced
Skip ahead: If you already write T extends U ? X : Y and know what infer does, skip to "Stop nesting, start naming", then continue.
Most TypeScript you write describes data you already have. A conditional type is different: it computes one type from another. It is the part of the language that frightens people, and I understand why. The syntax looks like a puzzle and the error messages are long. But there are only two ideas here, the extends check and infer, and once you can read both you can read most of the type-heavy code in a library.
A conditional type is an if for types
type IsString<T> = T extends string ? "yes" : "no";
const a: IsString<string> = "no";
const b: IsString<42> = "yes";
if_types.ts(3,7): error TS2322: Type '"no"' is not assignable to type '"yes"'.
if_types.ts(4,7): error TS2322: Type '"yes"' is not assignable to type '"no"'.
T extends string asks whether T can be assigned to string. If it can, you get the first branch, otherwise the second. The two errors above are the compiler telling you what each branch gave: IsString<string> is "yes" and IsString<42> is "no". This is a handy trick in its own right. When you cannot see what a computed type is, assign something wrong to it and read the message.
Distribution over a union
When the checked type is a bare type parameter and you pass a union, the check runs once for each member and the results are joined.
type IsString<T> = T extends string ? "yes" : "no";
type Wrapped<T> = [T] extends [string] ? "yes" : "no";
const a: IsString<string | number> = "maybe";
const b: Wrapped<string | number> = "maybe";
distribute.ts(4,7): error TS2322: Type '"maybe"' is not assignable to type '"no" | "yes"'.
distribute.ts(5,7): error TS2322: Type '"maybe"' is not assignable to type '"no"'.
IsString<string | number> became "no" | "yes", one answer per member (the compiler lists the members in its own order). Wrapping both sides in square brackets switches distribution off: the union is checked as a whole, and the whole is not a string, so you get "no".
Most of the time distribution is what you want. It is how the built-in Exclude<"a" | "b", "a"> gives you "b" without any extra code. It bites when you write a check for "is this exactly X" and a union goes in.
infer names a piece you did not write
infer appears only inside the extends part of a conditional type. It says: if the type matches this shape, give me the part at this position and call it by this name.
type ElementOf<T> = T extends (infer U)[] ? U : never;
type FirstArg<F> = F extends (first: infer A, ...rest: any[]) => any ? A : never;
function repeat(text: string, times: number): string {
return text.repeat(times);
}
type Item = ElementOf<string[]>;
type Arg = FirstArg<typeof repeat>;
const item: Item = "pen";
const arg: Arg = "ha";
console.log(item, arg);
ElementOf<string[]> is string, because string[] matches (infer U)[] with U as string. FirstArg<typeof repeat> is the type of the first parameter, also string. You can put infer at the element of an array, at a parameter, at the return type, inside a tuple or inside a promise. Those are the places the standard library uses it: Parameters, ReturnType and Awaited are all one or two lines of infer.
How do you know the type is right?
You want a test that fails when the type changes. A type test costs nothing at run time. It is one helper and one line per case.
type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Unwrap<T> = T extends Promise<infer V> ? V : T;
const ok: Equal<Unwrap<Promise<number>>, number> = true;
const alsoOk: Equal<Unwrap<boolean>, boolean> = true;
const wrong: Equal<Unwrap<Promise<string>>, number> = true;
type_test.ts(8,7): error TS2322: Type 'true' is not assignable to type 'false'.
Do not try to understand Equal line by line. It is a well-known trick for comparing two types exactly, and you copy it. What matters is the result: true means the types are identical, and anything else makes the assignment fail. Put one beside every conditional type you share with other people.
Stop nesting, start naming
Here is the shape that gives conditional types a bad name. The goal is to get the value a function resolves to, whether it returns a promise or not, and everything is inlined.
type Resolved<F> = F extends (...args: any[]) => infer R
? R extends Promise<infer V>
? V extends Promise<infer W>
? W
: V
: R
: never;
async function loadUser() {
return { id: 7, name: "Ana" };
}
const user: Resolved<typeof loadUser> = { id: 7, name: "Ana" };
console.log(user.name);
It works, and nobody wants to maintain it. Three levels deep, the second infer repeats the first, and a promise inside a promise needs a fourth. Give each step a name and use what the language already provides.
type Resolved<F extends (...args: any[]) => unknown> = Awaited<ReturnType<F>>;
async function loadUser() {
return { id: 7, name: "Ana" };
}
const user: Resolved<typeof loadUser> = { id: 7, name: "Ana" };
console.log(user.name);
Ana
Same result, one line. ReturnType extracts the return type and Awaited unwraps any depth of promise. The constraint on F also moved the failure to where it belongs: passing something that is not a function is now an error at the call site instead of a silent never.
💡 Tip: A conditional type with more than two levels is a sign that you are missing a helper. Name the middle step as its own type, test it with Equal, then compose.
Two ways this goes wrong
- A check that falls through to
never.ElementOf<number>isnever, andneveris assignable to everything, so the mistake travels quietly. If a wrong input should be an error, constrain the parameter (T extends unknown[]) instead of returningnever. anyin a conditional.any extends string ? "yes" : "no"gives"yes" | "no", both branches at once. Ifanyis leaking into a computed type, you will see unions you cannot explain.
⚠ Warning: Do not use a conditional type when a plain generic would do. function first<T>(list: T[]): T | undefined needs no extends at all. Conditional types are for when the output type depends on what the input type is.
If you skip this, you will meet conditional types anyway, in every library's declaration files, and you will treat them as magic. Which type in your own code is a union you wrote by hand, and would be one line if it were computed?
About
Read a conditional type as an if for types, see how it spreads over a union and use infer to take a piece out of a type. After this, you can name each step of a nested type, test it and stop copying type puzzles you cannot read.
- Duration
- 10 min
- Last update
- 10/12/2026
Comments (0)
Sign in to commentNo comments yet.