Conditional Types & infer

Reviewed & published by Brayan K

A conditional type uses the form T extends U ? X : Y to choose one type when T is assignable to U and another type otherwise — an "if/else" at the type level — and the infer keyword lets you capture and extract a sub-type from inside the type you are matching against.

Part of the free TypeScript course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

💡 Real-World Analogy

A conditional type is a sorting machine with a single question: "does this fit through the U-shaped hole?" If yes, it goes down chute X; if no, chute Y. Feed it a whole bag of items (a union) and it sorts each item independently — that's distribution. The infer keyword is like a label-reader built into the machine: as an item passes the test, it peels off and remembers a sticker from inside it (the return type, the element type, the first argument) so you can use that captured piece later.

1. T extends U ? X : Y

A conditional type asks one question: is T assignable to U? If so the type resolves to X, otherwise to Y. You can chain them like an else if ladder. This is exactly how NonNullable<T> strips null and undefined from a type:

// Single condition
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<"hi">;   // "yes"
type B = IsString<42>;     // "no"

// Chained — pick a label per category
type Kind<T> =
  T extends string  ? "text"   :
  T extends number  ? "numeric":
  T extends boolean ? "flag"   :
                      "other";
type C = Kind<true>;       // "flag"

// The built-in NonNullable is a conditional type:
type NonNullable<T> = T extends null | undefined ? never : T;
type D = NonNullable<string | null | undefined>;   // string
// === Conditional types: T extends U ? X : Y  — a type-level if/else ===
// At runtime, the analogous "choose based on a test" is a normal condition.
// In TS:  type IsString<T> = T extends string ? "yes" : "no";
// The runtime version of "does this value fit the test?":
function isString(value) {
  return typeof value === "string" ? "yes" : "no";
}
console.log(isString("hello"));  // yes
console.log(isString(42));       // no
console.log(isString(true));     // no

// A conditional type often picks ONE of two result types. The runtime
// equivalent is picking one of two RESULT VALUES based on the input shape.
// In TS:  type Labelled<T> = T extends number ? `n:${T}` : `s:${T}`;
function label(value) {
  return typeof value === "number" ? "n:" + value : "s:" + value;
}
console.log(label(7));      // n:7
console.log(label("hi"));   // s:hi

// Conditional types are how NonNullable<T> works:
//   type NonNullable<T> = T extends null | undefined ? never : T;
// Runtime: filter out null/undefined from a list of values.
const mixed = [1, null, 2, undefined, 3];
const clean = mixed.filter((x) => x !== null && x !== undefined);
console.log("clean:", clean);   // clean: [ 1, 2, 3 ]

2. Distributive Conditional Types

When the checked type is a bare type parameter and you feed it a union, the conditional applies to each member separately and re-unions the results. That is why ToArray<string | number> becomes string[] | number[], and why filtering a union just works. Wrap both sides in [brackets] to switch distribution off and treat the union as one unit.

// Distributive: applied to each union member
type ToArray<T> = T extends any ? T[] : never;
type E = ToArray<string | number>;   // string[] | number[]

// Filtering a union distributes — keep only string members
type Strings<T> = T extends string ? T : never;
type F = Strings<"a" | 1 | "b" | 2>; // "a" | "b"

// Non-distributive: wrap in [brackets] to treat T as a whole
type IsExactlyString<T> = [T] extends [string] ? true : false;
type G = IsExactlyString<string | number>;   // false (the WHOLE union)
type H = IsExactlyString<string>;            // true
// === Distributive conditional types ===
// When the checked type is a "naked" type parameter and you pass a UNION,
// the conditional DISTRIBUTES over each member:
//   type ToArray<T> = T extends any ? T[] : never;
//   ToArray<string | number>  =>  string[] | number[]
// Runtime analogue: apply the same operation to EACH member of a list.
const members = ["string", "number", "boolean"];
const arrayified = members.map((m) => m + "[]");
console.log("distributed:", arrayified);
// distributed: [ 'string[]', 'number[]', 'boolean[]' ]

// Filtering a union distributes too — each member is tested separately.
//   type Strings<T> = T extends string ? T : never;
//   Strings<"a" | 1 | "b" | 2>  =>  "a" | "b"
// Runtime: keep only the members that pass the test.
const union = ["a", 1, "b", 2, "c"];
const onlyStrings = union.filter((x) => typeof x === "string");
console.log("kept:", onlyStrings);   // kept: [ 'a', 'b', 'c' ]

// To STOP distribution in TS you wrap both sides in [brackets]:
//   type IsUnion<T> = [T] extends [any] ? ... // treats T as one whole unit
// Runtime analogue: test the list AS A WHOLE rather than per item.
const whole = union;
console.log("all strings?", whole.every((x) => typeof x === "string"));
// all strings? false

3. The infer Keyword

infer declares a placeholder type variable inside the extends pattern and captures whatever matches there. It is how you reach inside a function type for its return type, inside an array for its element type, or inside a Promise for its resolved value. Here is the actual definition of ReturnType alongside other infer helpers:

// This IS the built-in ReturnType
type ReturnType<F> = F extends (...args: any[]) => infer R ? R : never;
type R1 = ReturnType<() => string>;   // string

// Element type of an array
type ElementOf<T> = T extends (infer E)[] ? E : never;
type R2 = ElementOf<number[]>;        // number

// Resolved type of a Promise
type Awaited1<T> = T extends Promise<infer U> ? U : T;
type R3 = Awaited1<Promise<boolean>>; // boolean

// First parameter of a function
type FirstArg<F> = F extends (a: infer A, ...rest: any[]) => any ? A : never;
type R4 = FirstArg<(name: string, age: number) => void>;  // string
// === The infer keyword — extract a type from inside another type ===
// infer introduces a NEW type variable inside a conditional's extends clause
// and captures part of the matched type. The classic example is ReturnType:
//   type ReturnType<F> = F extends (...a: any[]) => infer R ? R : never;
// "If F is a function returning R, give me R."
// Runtime analogue: actually CALL the function and read its result.
function getReturn(fn, ...args) {
  return fn(...args);                 // the value whose TYPE infer R captures
}
const makeUser = (name) => ({ id: 1, name, active: true });
const result = getReturn(makeUser, "Alice");
console.log("inferred return value:", result);
// inferred return value: { id: 1, name: 'Alice', active: true }
console.log("its keys:", Object.keys(result)); // its keys: [ 'id', 'name', 'active' ]

// infer can pull the element type out of an array:
//   type ElementOf<T> = T extends (infer E)[] ? E : never;
// Runtime analogue: read one element to see what's inside.
const nums = [10, 20, 30];
console.log("element sample:", nums[0], "-> typeof", typeof nums[0]);
// element sample: 10 -> typeof number

// infer can pull the first parameter out of a function:
//   type FirstArg<F> = F extends (a: infer A, ...rest: any[]) => any ? A : never;
function firstArgOf(fn, ...args) { return args[0]; }
console.log("first arg was:", firstArgOf(makeUser, "Bob")); // first arg was: Bob

🎯 Your Turn

Implement the value-level form of a chained conditional type that describes a value. Fill in the blanks and match the expected output.

// 🎯 YOUR TURN — replace each ___ then press "Try it Yourself".
// Build the runtime version of a chained conditional type:
//   type Describe<T> =
//     T extends any[] ? "array" :
//     T extends null  ? "null"  : (typeof T);

function describeType(value) {
  // 1) Is it an array? (use Array.isArray)
  if (___) return "array";          // 👉 Array.isArray(value)
  // 2) Is it exactly null?
  if (value === ___) return "null"; // 👉 null
  // 3) Otherwise fall back to typeof
  return ___;                       // 👉 typeof value
}

console.log(describeType([1, 2, 3]));  // array
console.log(describeType("hello"));    // string
console.log(describeType(42));         // number
console.log(describeType(null));       // null

// ✅ Expected output:
//    array
//    string
//    number
//    null

Common Errors (and the fix)

Pro Tips

Mini-Challenge: Unwrap

Build the runtime form of a chained infer conditional that unwraps a Promise or an array, and otherwise returns the value untouched. Follow the outline, run it, and match the example output.

// 🎯 MINI-CHALLENGE: a runtime "Unwrap" mirroring infer
// In TS this is a chained conditional with infer:
//   type Unwrap<T> =
//     T extends Promise<infer U> ? U :
//     T extends (infer E)[]      ? E :
//     T;
//
// Write unwrap(value) that:
//   1. If value is a Promise (it has a .then function), return "promise-inner".
//   2. Else if value is an array, return its first element
//      (or "empty-array" if it's empty).
//   3. Else return value unchanged.
//
// Test with: [99, 100], "plain", and Promise.resolve(5)
//
// ✅ Example output:
//    99
//    plain
//    promise-inner

// your code here

🎉 Lesson Complete

Practice quiz

What is the syntax of a conditional type?

  • T ? U : X
  • if T then X else Y
  • T extends U ? X : Y
  • T | U ? X : Y

Answer: T extends U ? X : Y. A conditional type is T extends U ? X : Y - a type-level if/else that resolves to X when T is assignable to U, else Y.

When does a conditional type run?

  • At compile time, choosing a type
  • At runtime, choosing a value
  • Only inside async functions
  • Never - it is just documentation

Answer: At compile time, choosing a type. Conditional types resolve at compile time and choose a type, then are erased - nothing executes at runtime.

In a conditional type, what does extends actually mean?

  • Class inheritance
  • Has the same name as
  • Is a subclass of
  • Is assignable to

Answer: Is assignable to. extends here means 'is assignable to', not inheritance. So "hi" extends string is true, and 0 extends number is true.

What does ToArray<T> = T extends any ? T[] : never give for ToArray<string | number>?

  • (string | number)[]
  • string[] | number[]
  • never
  • Array<string | number>

Answer: string[] | number[]. A distributive conditional applies to each union member separately, so the result is string[] | number[], not a mixed array.

What triggers distribution in a conditional type?

  • A bare ('naked') type parameter checked against a union
  • Any conditional type
  • Using infer
  • Wrapping the type in brackets

Answer: A bare ('naked') type parameter checked against a union. Distribution happens when the checked type is a naked type parameter T and you pass a union; it then applies per member.

How do you DISABLE distribution and treat a union as a single unit?

  • Use the never keyword
  • Add the readonly modifier
  • Wrap both sides in brackets: [T] extends [U] ? ...
  • Replace extends with equals

Answer: Wrap both sides in brackets: [T] extends [U] ? .... Wrapping both sides in [brackets] - [T] extends [U] - stops distribution and tests the whole union at once.

What does the infer keyword do?

  • Runs a value-level inference
  • Declares a fresh type variable inside an extends pattern to capture a sub-type
  • Converts a type to any
  • Imports a type from another module

Answer: Declares a fresh type variable inside an extends pattern to capture a sub-type. infer introduces a new type variable inside a conditional's extends clause and captures whatever matches at that position.

How is the built-in ReturnType<F> defined?

  • F extends infer R ? R : F
  • Awaited<F>
  • F[number]
  • F extends (...args: any[]) => infer R ? R : never

Answer: F extends (...args: any[]) => infer R ? R : never. ReturnType<F> = F extends (...args: any[]) => infer R ? R : never - 'if F is a function returning R, give me R'.

Where is infer legal?

  • Anywhere a type can appear
  • Only in the extends clause of a conditional type
  • Only in function signatures
  • Only with Promise types

Answer: Only in the extends clause of a conditional type. infer is only valid inside the extends clause of a conditional type; using it elsewhere is an error.

Why might a conditional type resolve to never unexpectedly?

  • never is a syntax error
  • infer always returns never
  • Distribution can filter every union member out, leaving never
  • extends is misspelled

Answer: Distribution can filter every union member out, leaving never. never is the natural empty result; a distributive conditional that filters out every member leaves never. Trace each member.

Continue this course

Frequently asked questions

How is a conditional type different from a normal if statement?

A conditional type runs at COMPILE time and chooses a TYPE, not a value. The syntax T extends U ? X : Y reads 'if the type T is assignable to U, the resulting type is X, otherwise Y'. Nothing executes at runtime — TypeScript resolves the branch while type-checking and then erases it. An if statement, by contrast, runs at runtime and chooses a value. The runnable boxes in this lesson show the value-level analogue so you can feel the same logic in action.

What does 'distributive' mean for conditional types?

When the type being checked is a bare ('naked') type parameter T and you pass a UNION, the conditional is applied to each member of the union separately, then the results are unioned back together. So ToArray<string | number> with type ToArray<T> = T extends any ? T[] : never becomes string[] | number[], not (string | number)[]. If you don't want that, wrap both sides in square brackets — [T] extends [U] ? … — which makes TypeScript treat the union as a single unit and disables distribution.

What exactly does the infer keyword do?

infer declares a fresh type variable INSIDE the extends clause of a conditional type, capturing whatever matches at that position. For example, F extends (...args: any[]) => infer R ? R : never says 'if F is a function type, let R be its return type, and give me R'. You can place infer almost anywhere a type can appear in a pattern — array element ((infer E)[]), tuple position, Promise<infer U>, a property type, and so on — to pull a sub-type out of a larger one.

Why does my conditional type return never sometimes?

never is the natural 'no match / empty' result. Two common causes: (1) a distributive conditional filtered every union member out, leaving never; or (2) your false branch is never on purpose, like NonNullable<T> = T extends null | undefined ? never : T, which removes null and undefined from a union. If you didn't expect never, check whether distribution emptied the union, or whether the extends check is stricter than you intended.