Conditional Types in TypeScript: Making Decisions at the Type Level
We look at the T extends U ? X : Y syntax, what extends means for assignability, distributive behavior over unions, turning it off with [T] and filtering unions with never, backed by real tsc output.
Version note: The code in this post targets TypeScript 7.0 and Node 26.
We assume you have read the Basics and Intermediate series and are comfortable with generics and utility types. In the Advanced series we learn to derive types from other types instead of writing them by hand, and the first stop is conditional types, which let you make decisions at the type level.
Predict First
Below is a one-line generic type. We use it with three different types:
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<"hello">;
type B = IsString<string | number>;
type C = IsString<never>;
Which types are A, B and C? A looks easy. B has two candidates: "no", because string | number is not a string, or something else. C is where most people guess wrong on the first try. Write your guesses down; the answers come once we have seen how conditional types work with unions.
A Ternary Operator at the Type Level
In the JavaScript series we used the condition ? a : b ternary operator countless times. A conditional type, introduced in TypeScript 2.8, carries the same idea over to types:
// In JavaScript, on values: condition ? a : b
const score = 42;
const label = score > 10 ? "big" : "small";
// In TypeScript, on types: T extends U ? X : Y
type Label<T> = T extends string ? "text" : "other";
type L1 = Label<"Ada">; // "text"
type L2 = Label<42>; // "other"
Read it like this: "if T is assignable to string, the result is "text"; if not, it is "other"." The condition is always built with extends; there are no operators like == or &&. Both branches are types, and you can nest them as deeply as you like.
In real code, conditional types usually live inside a generic and produce a different result depending on the type that comes in. Here is a small but useful example, combined with the indexed access we saw in the Intermediate series:
type ElementType<T> = T extends readonly unknown[] ? T[number] : T;
type E1 = ElementType<string[]>; // string
type E2 = ElementType<readonly [1, 2]>; // 1 | 2
type E3 = ElementType<boolean>; // boolean
If an array or tuple comes in, we return the type of its elements; otherwise we return the type itself. We use readonly unknown[] because we want both regular and readonly arrays to match the pattern.
Seeing the Result: console.log at the Type Level
When we work with values, we see results with console.log. There is no such thing at the type level; types never reach runtime. Hovering over the type name in your editor or in the TypeScript Playground is the quickest way. If you work in the terminal, or you want to see the result for certain, there is a simple trick: assign a value that can't possibly fit that type, and read the compiler's complaint.
type ElementType<T> = T extends readonly unknown[] ? T[number] : T;
// Error: Type 'null' is not assignable to type '1 | 2'.
const reveal: ElementType<readonly [1, 2]> = null;
The output of npx tsc --noEmit is exactly this:
error TS2322: Type 'null' is not assignable to type '1 | 2'.
Since null is assignable to almost nothing in strict mode, the compiler spells out the type it computed right in the message. One subtlety: if you first define an alias such as type E2 = … and then write const reveal: E2 = null, the message only says 'E2' and doesn't expand it. So put the expression directly into the annotation. We checked every result comment in this post this way with TypeScript 7.0.2. The order of union members may look different in the Playground; TypeScript 7 prints unions in a stable order, but order carries no meaning in a union.
What Does extends Mean Here?
The extends in a conditional type is not about class inheritance; it asks about assignability. The question we set up while discussing structural typing in the Basics series applies unchanged: "Can every value of the type on the left be assigned to the type on the right?"
type IsString<T> = T extends string ? "yes" : "no";
type R1 = IsString<"hello">; // "yes": a literal type is assignable to string
type R2 = IsString<string>; // "yes"
type R3 = IsString<unknown>; // "no"
type R4 = IsString<any>; // "no" | "yes"
type HasId<T> = T extends { id: number } ? true : false;
type R5 = HasId<{ id: 1; name: "Ada" }>; // true: an extra property is fine
R5 is a direct consequence of structural typing: the type { id: 1; name: "Ada" } can go anywhere { id: number } is expected. R4 is a special case: because any behaves as both assignable and not assignable, the compiler takes both branches and returns their union. Keep in the back of your mind that any causes surprises at the type level too.
Distributive Conditional Types
Now let's get to B from the prediction. Look at this type:
type ToArray<T> = T extends unknown ? T[] : never;
type A1 = ToArray<string | number>; // string[] | number[]
type A2 = ToArray<boolean>; // false[] | true[]
You might have expected (string | number)[], but the result is string[] | number[]. The reason is this rule: if the left side of the condition is a naked type parameter and that parameter receives a union, the conditional type is applied to each member of the union separately, and the results are joined back into a union. So for ToArray<string | number> the compiler actually computes ToArray<string> | ToArray<number>. This is called distributive behavior.
A2 looks odd at first, but boolean is really the union true | false, so it distributes too. B from the prediction resolves the same way: IsString<string> gives "yes", IsString<number> gives "no", and together they make "no" | "yes".
Distribution only happens when two conditions hold together: a type parameter stands alone on the left of the condition, and that parameter is filled with a union in a generic use. If you put the parameter inside a structure, or write the union directly, there is no distribution:
type Wrap<T> = T[] extends string[] ? "strings" : "other";
type W1 = Wrap<string | number>; // "other"
type Direct = string | number extends string ? "yes" : "no"; // "no"
Turning Distribution Off: the [T] Trick
Sometimes you want to evaluate the union as a whole. To do that, wrap both sides in a one-element tuple. [T] is no longer a naked parameter, so nothing distributes:
type ToArrayAll<T> = [T] extends [unknown] ? T[] : never;
type A3 = ToArrayAll<string | number>; // (string | number)[]
type IsNever<T> = [T] extends [never] ? true : false;
type N1 = IsNever<never>; // true
type N2 = IsNever<string>; // false
type IsNeverWrong<T> = T extends never ? true : false;
type N3 = IsNeverWrong<never>; // never
N3 also answers C from the prediction. A distributive conditional type runs once for each member of the union. never is the union with no members; with nothing to distribute over, the result is an empty union too, which is never. That's why IsString<never> is neither "yes" nor "no", but simply never. If you want to ask whether a type is never, memorize the [T] extends [never] pattern.
Filtering with never
Distribution may look like a trap at first, but it is one of the most useful features. never vanishes from a union without a trace (X | never is just X). Combine the two and you get a filter for unions:
type OnlyStrings<T> = T extends string ? T : never;
type S1 = OnlyStrings<"click" | 42 | "scroll" | true>; // "click" | "scroll"
Each member is checked on its own: strings stay as they are, everything else becomes never and disappears. The Exclude and Extract we used in the Intermediate series are exactly this idea. Here are their definitions in TypeScript's own lib file:
// From lib.es5.d.ts; they are already global, so don't copy them into your own file
type Exclude<T, U> = T extends U ? never : T;
type Extract<T, U> = T extends U ? T : never;
Because the assignability check works with object types as well, this is a perfect fit for filtering discriminated unions:
type Action =
| { type: "add"; item: string }
| { type: "remove"; id: number }
| { type: "clear" };
type PayloadAction = Exclude<Action, { type: "clear" }>;
type PayloadType = PayloadAction["type"]; // "add" | "remove"
function logPayload(action: PayloadAction) {
if (action.type === "add") {
console.log(`add ${action.item}`);
} else {
console.log(`remove ${action.id}`);
}
}
logPayload({ type: "add", item: "Keyboard" }); // add Keyboard
The { type: "clear" } member is assignable to { type: "clear" }, so it becomes never and drops out of the union. Both remaining members carry some data. NonNullable used to be written with the same pattern; in the current lib it is defined as T & {}, but the result is the same.
Common Pitfalls
The first pitfall is writing a generic function's return type as a conditional type and expecting the body to satisfy it:
function describe<T extends string | number>(value: T): T extends string ? "text" : "number" {
// Error: Type '"text"' is not assignable to type 'T extends string ? "text" : "number"'.
return typeof value === "string" ? "text" : "number";
}
The compiler reports the same error (TS2322) for "number" on the same line. Inside the function, T is not known yet; the conditional type stays deferred, unresolved, and the narrowing you do with typeof doesn't change T itself. For an API like this, overloads are the more honest tool:
function describe(value: string): "text";
function describe(value: number): "number";
function describe(value: string | number): "text" | "number" {
return typeof value === "string" ? "text" : "number";
}
const kind = describe("Ada"); // "text"
console.log(kind); // text
The second pitfall is forgetting that boolean is a union. ToArray<boolean> coming out as false[] | true[] instead of boolean[] can turn into "why can't I push true into this array?" in real code. If you want the union as a whole, wrap it with [T].
The third is never spreading silently. Pass never to a distributive type and the result is never as well, which can surface a few layers later as confusing errors. The fourth is any: since it returns the union of both branches, don't expect a conditional type to pick the branch you want wherever any has leaked in.
A Small Experiment
★☆☆ Predict, then verify with the null trick: given type Flip<T> = T extends true ? false : true;, which type is Flip<boolean>? Keep distribution in mind.
★★☆ Write a RemoveNullish<T> that removes null and undefined from a union. Compare the result of RemoveNullish<string | null | undefined> with NonNullable.
★★★ With type Keys<T> = T extends unknown ? keyof T : never;, what does Keys<{ a: 1 } | { b: 2 }> give? And if you write keyof ({ a: 1 } | { b: 2 }) directly? Explain why the two differ using the distribution rule.
So far we have only asked yes-or-no questions with conditional types: "is this type assignable to that one?" In the next post we take the question one step further: with infer we will capture a piece from inside the type that matches the pattern and use it, and we will write ReturnType and Awaited ourselves.