Phase 5 · Type-level programming · Lesson 5.3
AdvancedConditional types and infer
If/else at the type level: `T extends U ? X : Y`, pulling types out of other types with `infer`, rebuilding ReturnType, Parameters and Awaited, and writing recursive types without hitting the depth limit.
25 min
keyof and mapped types transform shapes, but they can't make decisions. How do you say "if this is a function, give me what it returns, otherwise leave it alone"? Or "unwrap this Promise, however many layers deep"? Conditional types are the type system's if, and infer lets them capture a piece of the type they match. Together they power ReturnType, Parameters, Awaited and most of the clever types you'll be asked to write in an interview.
The shape: T extends U ? X : Y
A conditional type looks like the JavaScript ternary, but the test is assignability: "is T assignable to U?"
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type IsString<T> = T extends string ? "yes" : "no";
type _t1 = Expect<Equal<IsString<"hello">, "yes">>;
type _t2 = Expect<Equal<IsString<42>, "no">>;
type _t3 = Expect<Equal<IsString<string>, "yes">>;
type _t4 = Expect<Equal<IsString<unknown>, "no">>;extends here does not mean inheritance. It means "fits inside". "hello" fits inside string, so the answer is "yes". unknown does not fit inside string (it could be anything), so the answer is "no".
You can chain them like nested ternaries:
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type TypeName<T> =
T extends string ? "string" :
T extends number ? "number" :
T extends boolean ? "boolean" :
T extends (...args: any[]) => any ? "function" :
"object";
type _t1 = Expect<Equal<TypeName<"a">, "string">>;
type _t2 = Expect<Equal<TypeName<() => void>, "function">>;
type _t3 = Expect<Equal<TypeName<Date>, "object">>;infer: capture part of a type
Inside the extends clause you can declare a new type variable with infer. If the match succeeds, the variable holds whatever sat in that position, and you can use it in the true branch.
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type ElementOf<T> = T extends (infer E)[] ? E : never;
type _t1 = Expect<Equal<ElementOf<string[]>, string>>;
type _t2 = Expect<Equal<ElementOf<(number | boolean)[]>, number | boolean>>;
type _t3 = Expect<Equal<ElementOf<string>, never>>;Read T extends (infer E)[] as a pattern: "does T look like an array of something? If so, call that something E." infer is only allowed in the extends clause of a conditional type, and the variable only exists in the true branch.
Function positions: rebuilding ReturnType and Parameters
Put infer in a function type's return position or parameter list:
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type MyReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
type MyParameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;
function createUser(name: string, age?: number) {
return { name, age, createdAt: new Date() };
}
type Created = MyReturnType<typeof createUser>;
type _t1 = Expect<Equal<Created, { name: string; age: number | undefined; createdAt: Date }>>;
type Args = MyParameters<typeof createUser>;
type _t2 = Expect<Equal<Args, [name: string, age?: number]>>;
type FirstArg = Args[0];
type _t3 = Expect<Equal<FirstArg, string>>;These match the standard library definitions almost exactly. Parameters returns a tuple, labels and optional elements included, so you can index it (Args[0]) or spread it into another function's signature.
Promise positions: unwrapping
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type UnwrapOnce<T> = T extends Promise<infer V> ? V : T;
type _t1 = Expect<Equal<UnwrapOnce<Promise<string>>, string>>;
type _t2 = Expect<Equal<UnwrapOnce<Promise<Promise<string>>>, Promise<string>>>;
type _t3 = Expect<Equal<UnwrapOnce<number>, number>>;
async function loadCount() {
return 42;
}
type Count = Awaited<ReturnType<typeof loadCount>>;
type _t4 = Expect<Equal<Count, number>>;UnwrapOnce only peels one layer. The built-in Awaited peels them all, and Awaited<ReturnType<typeof fn>> is the idiom for "what an async function resolves to".
Tuple positions: head, tail, last
Tuple patterns can mix infer with rest elements:
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
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 : [];
type Last<T extends readonly unknown[]> = T extends readonly [...unknown[], infer L] ? L : never;
type _t1 = Expect<Equal<Head<[1, 2, 3]>, 1>>;
type _t2 = Expect<Equal<Tail<[1, 2, 3]>, [2, 3]>>;
type _t3 = Expect<Equal<Last<[1, 2, 3]>, 3>>;
type _t4 = Expect<Equal<Head<[]>, never>>;
type _t5 = Expect<Equal<Head<readonly ["a", "b"]>, "a">>;Notice the readonly in the patterns. A readonly tuple (for example, from as const) is not assignable to a mutable tuple pattern, so T extends [infer H, ...unknown[]] would quietly fail for it. Matching against readonly [...] accepts both.
Quick check
What is Second<["a", "b", "c"]>?
type Second<T> = T extends [unknown, infer S, ...unknown[]] ? S : never;Where infer appears more than once
If the same infer name appears in several places, TypeScript combines the candidates. In normal (covariant) positions like properties and return types, it takes the union. In parameter (contravariant) positions, it takes the intersection.
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type BothProps<T> = T extends { a: infer U; b: infer U } ? U : never;
type _t1 = Expect<Equal<BothProps<{ a: string; b: number }>, string | number>>;
type BothParams<T> = T extends { a: (x: infer U) => void; b: (x: infer U) => void } ? U : never;
type _t2 = Expect<Equal<BothParams<{ a: (x: { id: 1 }) => void; b: (x: { name: "n" }) => void }>, { id: 1 } & { name: "n" }>>;Why the intersection? A function that must be usable as both a and b has to accept an argument that satisfies both parameter types. This rule is the trick behind UnionToIntersection in the next lesson.
Constraining infer with extends
Since TypeScript 4.7 you can add a constraint directly to an infer: infer U extends string. The match only succeeds if the captured type satisfies the constraint, and inside the true branch U is known to satisfy it.
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type FirstString<T> = T extends [infer S extends string, ...unknown[]] ? S : never;
type _t1 = Expect<Equal<FirstString<["a", 1]>, "a">>;
type _t2 = Expect<Equal<FirstString<[1, "a"]>, never>>;
type ToNumber<S extends string> = S extends `${infer N extends number}` ? N : never;
type _t3 = Expect<Equal<ToNumber<"42">, 42>>;
type _t4 = Expect<Equal<ToNumber<"abc">, never>>;ToNumber is the neat one: with an extends number constraint in a template literal, TypeScript (4.8+) parses the string and gives you the numeric literal 42, not just number. Without the constraint you would need a second nested conditional (S extends ... ? (N extends number ? N : never) : never).
Recursive conditional types
A conditional type can refer to itself. That is how you process structures of unknown depth.
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Flatten<T> = T extends readonly (infer U)[] ? Flatten<U> : T;
type _t1 = Expect<Equal<Flatten<number[][][]>, number>>;
type _t2 = Expect<Equal<Flatten<(string | boolean[])[]>, string | boolean>>;
type DeepAwaited<T> = T extends PromiseLike<infer U> ? DeepAwaited<U> : T;
type _t3 = Expect<Equal<DeepAwaited<Promise<Promise<Promise<Date>>>>, Date>>;
type Reverse<T extends unknown[]> = T extends [infer H, ...infer R] ? [...Reverse<R>, H] : [];
type _t4 = Expect<Equal<Reverse<[1, 2, 3]>, [3, 2, 1]>>;▶ Try it in the TypeScript Playground
DeepAwaited is a simplified Awaited. The real definition in lib.es5.d.ts is longer because it handles more cases:
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type MyAwaited<T> =
T extends null | undefined ? T :
T extends object & { then(onfulfilled: infer F, ...args: infer _): any }
? F extends (value: infer V, ...args: infer _) => any
? MyAwaited<V>
: never
: T;
type _t1 = Expect<Equal<MyAwaited<Promise<Promise<number>>>, number>>;
type _t2 = Expect<Equal<MyAwaited<string | Promise<boolean>>, string | boolean>>;
type _t3 = Expect<Equal<MyAwaited<null>, null>>;It matches any thenable (an object with a then method), not only Promise, because await works on any thenable at runtime. It reads the value type from the first parameter of the onfulfilled callback. And it passes null and undefined through untouched.
Depth limits
Type recursion is not unlimited. When TypeScript gives up, you get Type instantiation is excessively deep and possibly infinite.
- A tail-recursive conditional type (the recursive call is the entire branch result, like
FlattenandDeepAwaited) is optimised since TypeScript 4.5 and can recurse up to 1000 times. - A non-tail-recursive type (the recursive call is wrapped in something, like
[...Reverse<R>, H]) hits the general instantiation depth limit much earlier, after roughly 50 levels in practice.
type BuildTuple<N extends number, Acc extends unknown[] = []> =
Acc["length"] extends N ? Acc : BuildTuple<N, [...Acc, unknown]>;
type Ok = BuildTuple<999>["length"];
// ^? type Ok = 999
// @ts-expect-error -- Type instantiation is excessively deep and possibly infinite.
type TooDeep = BuildTuple<2000>["length"];BuildTuple passes its progress along in an accumulator (Acc), which keeps the recursive call in tail position. Rewriting a recursive type to use an accumulator is the standard fix when you hit the limit.
Deferred conditional types
A conditional type can only be resolved when TypeScript knows what T is. Inside a generic function, T is unknown, so the conditional stays deferred: an unevaluated type that nothing concrete is assignable to.
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Label<T extends string | number> = T extends string ? "text" : "count";
function label<T extends string | number>(value: T): Label<T> {
// @ts-expect-error -- Type '"text"' is not assignable to type 'Label<T>'.
return typeof value === "string" ? "text" : "count";
}
const a = label("hi");
type _t1 = Expect<Equal<typeof a, "text">>;
const b = label(3);
type _t2 = Expect<Equal<typeof b, "count">>;For callers, the return type works perfectly: label("hi") resolves to "text". Inside the body, narrowing value with typeof does not narrow the type parameter T, so TypeScript can't prove "text" matches Label<T>. The usual solutions are a type assertion in the implementation (as Label<T>), or function overloads, which is what most library code does.
Spot the error
This type should give the element type of any array, including arrays that came from as const. It returns the wrong thing for ROLES. Why?
type ElementOf<T> = T extends (infer E)[] ? E : T;
const ROLES = ["admin", "user"] as const;
type Role = ElementOf<typeof ROLES>;
// ^? type Role = readonly ["admin", "user"]Show the answer
as const makes ROLES a readonly tuple, and a readonly array is not assignable to a mutable array type E[] (it lacks push, pop, and so on). The match fails, and the false branch returns T itself. Match against a readonly array, which accepts both mutable and readonly arrays:
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type ElementOf<T> = T extends readonly (infer E)[] ? E : T;
const ROLES = ["admin", "user"] as const;
type Role = ElementOf<typeof ROLES>;
type _t1 = Expect<Equal<Role, "admin" | "user">>;
type _t2 = Expect<Equal<ElementOf<number[]>, number>>;Quick check
What is R?
declare function parse(input: string): string;
declare function parse(input: number): number;
type R = ReturnType<typeof parse>;Recap
T extends U ? X : Ychecks assignability ofTtoU, not inheritance.infer Xcaptures part of the matched type; it's only valid in theextendsclause, andXonly exists in the true branch.ReturnType,ParametersandAwaitedare conditional types withinferin the return, parameter andthenpositions.- Overloaded functions:
ReturnTypeandParametersuse the last signature. - Match arrays and tuples with
readonly (infer E)[]orreadonly [...]soas constdata works. - The same
inferin several spots gives a union (covariant spots) or an intersection (parameter spots). infer N extends numberin a template literal parses a string into a numeric literal.- Tail-recursive conditional types can recurse about 1000 times; non-tail ones fail much sooner. Use an accumulator.
- Conditional types on an unresolved
Tare deferred; inside the generic body you need an assertion or overloads.
Interview cards
1 / 7