TypeScript

Conditional types and infer

A type-level if/else — TypeScript can choose between two different types based on a check, and even extract a piece of a type it hasn't seen yet. This is the mechanism behind ReturnType and Awaited under the hood.

Advanced

4 min read

The basic shape: a type-level ternary

type IsString<T> = T extends string ? "yes" : "no";
 
type A = IsString<string>;    // "yes"
type B = IsString<number>;    // "no"

T extends string ? "yes" : "no" is a conditional type — it reads almost exactly like JavaScript's own ternary operator, just operating on types at compile time rather than values at runtime. extends here isn't the same extends used for class inheritance or interface extension — in this position, it means "is T assignable to string," a compatibility check, and the conditional type resolves to whichever branch matches.

infer: extracting a piece of a type you haven't named yet

type ElementType<T> = T extends (infer U)[] ? U : never;
 
type A = ElementType<string[]>;   // string
type B = ElementType<number[]>;    // number
type C = ElementType<string>;       // never — string isn't an array at all

infer U inside the extends clause introduces a new type variable, U, and asks TypeScript to figure out what U would have to be for the whole extends check to succeed — then makes that inferred U available in the true branch. ElementType<string[]> works by pattern-matching string[] against the shape (infer U)[] — the only way that pattern matches is if U is string, so that's what gets inferred and returned. This is genuinely how TypeScript's own built-in ReturnType<T> is implemented internally: type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any — infer R as "whatever this function actually returns," pattern-matched directly out of the function type itself.

Where this actually gets used: unwrapping a Promise

type Unwrap<T> = T extends Promise<infer U> ? U : T;
 
type A = Unwrap<Promise<string>>; // string
type B = Unwrap<string>;           // string (unchanged — it wasn't a Promise to begin with)
 
async function fetchUser() {
  return { id: 1, name: "Ada" };
}
type FetchedUser = Awaited<ReturnType<typeof fetchUser>>;
// { id: number; name: string; } — not Promise<{ id: number; name: string; }>

Unwrap<T> is exactly the pattern behind TypeScript's own built-in Awaited<T> utility type: given a Promise<U>, extract and return just U; given anything that isn't a Promise, return it unchanged. Combined with ReturnType, this is how FetchedUser above correctly resolves to the resolved shape an async function eventually produces, not the Promise wrapper around it — genuinely useful, since an async function's declared return type is always technically Promise<...>, but what you almost always actually want is what's inside that promise once it resolves.

Distributive conditional types: a genuine, easy-to-miss quirk

type ToArray<T> = T extends unknown ? T[] : never;
 
type Result = ToArray<string | number>;
// string[] | number[] — NOT (string | number)[]!

When a conditional type's checked type parameter (T here) is a naked type parameter — used directly, not wrapped in something like T[] or [T] — and the conditional is applied to a union, TypeScript distributes the conditional across each member of the union separately, then unions the results back together. ToArray<string | number> doesn't run once with T = string | number; it effectively runs twice, once with T = string and once with T = number, producing string[] | number[] rather than the (also valid-looking, but different) (string | number)[]. This distributive behavior is usually exactly what's wanted, but it's a real, documented source of confusion the first time a conditional type produces a union of arrays instead of the array of a union that seemed more intuitive.

When to reach for this vs. when it's overkill

// Genuinely warranted — deriving a precise type from another type/function
type ApiResult<T> = T extends { error: infer E } ? { success: false; error: E } : { success: true; data: T };
 
// Overkill — a plain union would be simpler and just as correct
type Status<T> = T extends true ? "on" : "off"; // just write: "on" | "off" and pick one directly

Conditional types with infer are genuinely warranted when a type needs to be computed from another type that isn't known until it's actually used generically — deriving an API response shape, unwrapping a nested structure, building a type-safe event-handler map. For a fixed, small set of known possibilities, a plain union type (from the union-and-intersection-types lesson) is simpler, easier to read, and just as correct — conditional types earn their real complexity budget only when the actual computation genuinely depends on an as-yet-unknown T.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. In `type IsString<T> = T extends string ? "yes" : "no";`, what does `extends` mean in this position?

2. What does `infer U` inside `T extends (infer U)[] ? U : never` actually do?

3. Why does `type ToArray<T> = T extends unknown ? T[] : never;` applied to `string | number` produce `string[] | number[]`, not `(string | number)[]`?