Skip to Content
TypeScript Advanced: Types That Hold Up in Production
Advanced
0 likes
Courses
TypeScript Advanced: Types That Hold Up in Production

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.

Course Home

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.

Diagram: IsString of string checks string extends string and gives yes; IsString of 42 checks 42 extends string and gives no; ElementOf of string[] matches (infer U)[] and gives string

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> is never, and never is assignable to everything, so the mistake travels quietly. If a wrong input should be an error, constrain the parameter (T extends unknown[]) instead of returning never.
  • any in a conditional. any extends string ? "yes" : "no" gives "yes" | "no", both branches at once. If any is 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
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.