TypeScript Conditional Types
Conditional types let you write type-level logic: a type that resolves to one type or another depending on a condition, much like a ternary expression but evaluated by the compiler instead of at runtime. They are the engine behind many built-in utility types (Exclude, Extract, ReturnType, NonNullable) and let you build your own reusable, generic type transformations. Once you understand conditional types, a whole category of TypeScript’s “magic” utility types stops looking magic.
Overview / How it works
A conditional type has the form T extends U ? TrueType : FalseType. The compiler checks whether type T is assignable to type U. If it is, the conditional type resolves to TrueType; otherwise it resolves to FalseType. This check happens entirely during type checking — there is no runtime cost, and after compilation the JavaScript output contains no trace of the condition at all. Types are fully erased at runtime.
Conditional types become genuinely useful when T is a generic type parameter, because then the condition can’t be resolved immediately — it is deferred until the generic is instantiated with a concrete type. This is what allows you to write a single generic conditional type and have it produce different results for different callers.
Distributive conditional types
Here’s a subtlety that trips up almost everyone the first time: when the type being checked (the left side of extends) is a naked type parameter and you pass in a union type, the conditional type distributes over each member of the union automatically. In other words, Cond<A | B> becomes Cond<A> | Cond<B>. This is intentional — it’s exactly how built-in utilities like Exclude and Extract filter union members one at a time. But it means a conditional type you expect to answer a single yes/no question about a union as a whole might instead give you a union of per-member answers. We’ll see this in the Common Mistakes section, along with the fix (wrapping both sides in a tuple to suppress distribution).
The infer keyword
Inside the extends clause of a conditional type, you can use infer to introduce a new type variable that captures part of the matched structure, instead of just testing it. For example, T extends Promise<infer U> ? U : T checks whether T is a Promise of something, and if so, captures that “something” as U and returns it. infer is only legal in the extends clause of a conditional type — you cannot use it as a standalone declaration anywhere else.
Syntax
type ConditionalType<T, U, TrueType, FalseType> = T extends U ? TrueType : FalseType;
T— the type being tested, usually a generic type parameter.extends U— the condition: isTassignable toU?? TrueType— the type produced when the condition holds.: FalseType— the type produced when the condition fails.infer X(optional, insideU) — captures part of the matched shape into a new type variableX, usable inTrueType.
Examples
Example 1: A basic yes/no conditional type
type IsString<T> = T extends string ? "yes" : "no";
type CheckA = IsString<string>; // "yes"
type CheckB = IsString<number>; // "no"
function describe(value: unknown): string {
return typeof value === "string" ? "yes" : "no";
}
console.log(describe("hello"));
console.log(describe(42));
const a: CheckA = "yes";
const b: CheckB = "no";
console.log(a, b);
Output:
yes
no
yes no
Here IsString<T> resolves at the type level to the literal type "yes" or "no" depending on whether T is assignable to string. The runtime function describe mirrors the same logic with typeof, since the type-level check itself produces no runtime code — it only shapes what the compiler will accept for a and b.
Example 2: A distributive conditional type (reimplementing Exclude)
type MyExclude<T, U> = T extends U ? never : T;
type Status = "idle" | "loading" | "success" | "error";
type ErrorStatus = MyExclude<Status, "idle" | "loading">;
function logStatus(status: ErrorStatus): void {
console.log(`Status: ${status}`);
}
logStatus("success");
logStatus("error");
Output:
Status: success
Status: error
Because T in MyExclude<T, U> is a naked type parameter, passing the union Status causes the conditional to distribute: each member of Status is checked individually against "idle" | "loading". Members that match resolve to never (which drops out of the resulting union entirely), leaving only "success" | "error". This is precisely how TypeScript’s built-in Exclude<T, U> utility type is implemented.
Example 3: Capturing types with infer
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type A = UnwrapPromise<Promise<number>>; // number
type B = UnwrapPromise<string>; // string
type ElementType<T> = T extends (infer U)[] ? U : never;
type NumberArrayElement = ElementType<number[]>; // number
function sumAll(nums: NumberArrayElement[]): number {
return nums.reduce((total, n) => total + n, 0);
}
async function fetchValue(): Promise<UnwrapPromise<Promise<string>>> {
return "resolved value";
}
console.log(sumAll([1, 2, 3, 4]));
fetchValue().then((value) => console.log(value));
Output:
10
resolved value
UnwrapPromise uses infer U inside Promise<infer U> to pull the resolved type out of a Promise; if T isn’t a promise, it’s returned unchanged. ElementType does the same trick for arrays, pulling out the element type. Note the output order: sumAll(...) runs synchronously and logs 10 immediately, while the .then callback on the promise only runs afterward, once the current synchronous code has finished.
Under the hood
When the compiler encounters a conditional type where T is a concrete (non-generic) type, it resolves the condition immediately: it checks assignability and picks a branch right away, just like evaluating a ternary at compile time. When T is still an unresolved generic type parameter, the compiler cannot decide yet, so it keeps the conditional type unevaluated and defers the decision until the generic is instantiated with an actual type argument — this is what makes conditional types reusable across many call sites.
When the checked type is a naked generic type parameter and a union is substituted in, the compiler distributes the conditional across every member of the union, evaluates each one independently, and unions the results back together. Wrapping either side in a tuple ([T]) turns the type parameter into a non-naked position, which disables distribution and forces the union to be tested as a single, whole type.
As with all TypeScript types, none of this exists once compilation finishes. Type aliases, conditional types, and infer variables are compile-time-only constructs used to catch mistakes before your code runs; the emitted JavaScript contains only the runtime logic you wrote yourself (like the typeof checks or .reduce calls above), with zero references to IsString, MyExclude, or any other type name.
Common Mistakes
Mistake 1: Using infer outside an extends clause
type Broken<T> = infer U extends T ? U : never;
tsc reports: “‘infer’ declarations are only permitted in the ‘extends’ clause of a conditional type.” infer can only appear inside the type you’re comparing against on the right-hand side of extends — it cannot appear as the type being checked. The fix is to put the type you want to match (containing the infer variable) on the right of extends, with the type being inspected on the left:
type Unwrap<T> = T extends Array<infer U> ? U : never;
type Item = Unwrap<string[]>; // string
const items: Item[] = ["a", "b", "c"];
console.log(items.join(", "));
Output:
a, b, c
Mistake 2: Forgetting that naked type parameters distribute over unions
type IsArray<T> = T extends unknown[] ? true : false;
type Test = IsArray<string | number[]>; // boolean, not a single answer!
const surprising: Test = true;
console.log(surprising);
This compiles without error, but it’s almost certainly not what was intended. Because T is a naked type parameter, IsArray<string | number[]> distributes into IsArray<string> | IsArray<number[]>, which is false | true, i.e. boolean — not a definite yes/no answer about the union as a whole. If the goal is to ask “is this entire type an array type?”, wrap both sides in a tuple to suppress distribution:
type IsArrayFixed<T> = [T] extends [unknown[]] ? true : false;
type FixedTest = IsArrayFixed<string | number[]>; // false
const notAnArray: FixedTest = false;
console.log(notAnArray);
Output:
false
Now [T] is a single tuple type being tested against [unknown[]] as a whole, so the union isn’t split apart member-by-member, and the result correctly reflects that string | number[] as a whole is not an array type.
Best Practices
- Reach for the built-in utility types (
Exclude,Extract,NonNullable,ReturnType,Parameters,InstanceType,Awaited) before writing your own conditional type — they cover most common needs and are already battle-tested. - Only use
inferinside theextendsclause of a conditional type; it cannot be declared anywhere else. - When you want a conditional type to treat a union as one whole value rather than distributing member-by-member, wrap both sides in a tuple:
[T] extends [U] ? X : Y. - Give conditional types clear, descriptive names (
UnwrapPromise, notCond1) — they read like small functions and deserve the same naming care. - Keep conditional types shallow where possible; deeply nested or recursive conditional types can be slow to check and hard for teammates to reason about.
- Remember conditional types are erased at compile time — never rely on them to enforce behavior at runtime; pair them with real runtime checks (like
typeoforinstanceof) when the distinction actually matters while the program is running.
Practice Exercises
- Write a conditional type
IsFunction<T>that resolves totrueifTis any function type andfalseotherwise. Test it against a function type and againstnumber. - Write a conditional type
Flatten<T>that, usinginfer, unwraps one level of array nesting — soFlatten<string[]>isstringandFlatten<number>is justnumber(unchanged, since it isn’t an array). - Write a non-distributive conditional type
IsUnion<T>hint: compareTagainst itself using a helper generic parameter with a default, and think about how tuple-wrapping affects distribution when reasoning about how many branches the compiler actually evaluates for a union input.
Summary
- A conditional type has the form
T extends U ? TrueType : FalseType, evaluated by the compiler, not at runtime. - When
Tis a generic type parameter, evaluation is deferred until the generic is instantiated with a concrete type. - When the checked type is a naked type parameter, conditional types distribute over unions automatically — this powers utilities like
ExcludeandExtract, but can surprise you. - Wrap both sides in a tuple (
[T] extends [U] ? ... : ...) to disable distribution when you need to test a union as a single whole. inferlets you capture and reuse part of a matched type, but only inside theextendsclause of a conditional type.- All of this is compile-time-only: after compilation, type information — including every conditional type — is completely erased from the JavaScript output.
