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
- Write a conditional type with T extends U ? X : Y
- Chain conditionals to express multi-branch type logic
- Understand distributive conditional types over unions
- Disable distribution by wrapping in [brackets]
- Use infer to extract a sub-type from a pattern
- Build ReturnType-style helpers from scratch
💡 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? false3. 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
// nullCommon Errors (and the fix)
- ❌ Unexpected union splitting: a distributive conditional turns (string | number)[] goals into string[] | number[]. ✅ If you wanted the union kept whole, wrap both sides: [T] extends [U] ? ….
- ❌ infer outside a conditional: infer is only legal in the extends clause of a conditional type. ✅ Always pair it with T extends Pattern<infer X> ? X : ….
- ❌ Getting never from a filter you didn't mean: distribution can empty a union to never. ✅ Trace each union member through the condition to see which survive.
- ❌ Forgetting extends means assignable: 0 extends number is true, surprising people who read it as inheritance. ✅ Mentally substitute "is assignable to".
- ❌ Over-broad any in the pattern: matching (...args: any[]) => infer R is correct; using a too-specific signature can fail to match generic functions. ✅ Keep the pattern's non-captured parts as loose as possible (any[], any).
Pro Tips
- 💡 Chain conditionals like an else-if ladder for multi-way type decisions — they read top to bottom.
- 💡 Reach for the built-ins first: ReturnType, Parameters, InstanceType, Awaited, NonNullable are all conditional/infer types you don't need to rewrite.
- 💡 Use [T] extends [U] whenever you must test a union as a single unit (e.g. an exact-match or "is this exactly never" check).
- 💡 Place multiple infer variables in one pattern to capture several pieces at once (e.g. first arg and return type).
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
- ✅ T extends U ? X : Y is a type-level if/else; chain them for multi-branch logic
- ✅ extends means "is assignable to", not class inheritance
- ✅ Conditionals over a bare type parameter distribute across union members
- ✅ Wrap in [brackets] to disable distribution and test the union as a whole
- ✅ infer X captures a sub-type from a pattern (return type, element, resolved value, …)
- ✅ ReturnType, Parameters, Awaited, NonNullable are all conditional/infer types
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
- Previous: Mapped Types
- Next: Generic Constraints & Defaults — Restrict type parameters with extends and provide defaults
- Quick reference: TypeScript cheat sheet
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.