Skip to content

Conditional Types

A conditional type chooses one of two types based on a relationship, using the ternary syntax you already know — but at the type level:

type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<string>; // "yes"
type B = IsString<number>; // "no"
type C = IsString<"hi">; // "yes" ("hi" extends string)

Read T extends U ? X : Y as “if T is assignable to U, the type is X, otherwise Y.” The extends here is the same subset test from the foundations module — not inheritance.

Conditionals nest to form multi-way branches:

type TypeName<T> =
T extends string ? "string" :
T extends number ? "number" :
T extends boolean ? "boolean" :
T extends undefined ? "undefined" :
T extends Function ? "function" :
"object";
type T1 = TypeName<string>; // "string"
type T2 = TypeName<() => void>; // "function"
type T3 = TypeName<number[]>; // "object"

Each condition is tried in order, exactly like a chain of else if.

Distribution requires a naked type parameter

Section titled “Distribution requires a naked type parameter”

Here is the behavior that surprises everyone. When the checked type is a naked type parameter and you hand it a union, the conditional distributes over each member and unions the results:

type ToArray<T> = T extends any ? T[] : never;
type R = ToArray<string | number>;
// ^? string[] | number[]

It did not produce (string | number)[]. Instead the conditional ran separately for string (giving string[]) and for number (giving number[]), then unioned them. This is distributive conditional type behavior.

flowchart TB
  input["ToArray of string or number"] --> split["distribute over each member"]
  split --> s["string branch: string array"]
  split --> n["number branch: number array"]
  s --> out["union the results:
string array or number array"]
  n --> out
A conditional distributes over each union member

A practical use is filtering a union. Exclude (a built-in) is exactly this:

type MyExclude<T, U> = T extends U ? never : T;
type Colors = "red" | "green" | "blue";
type NoRed = MyExclude<Colors, "red">;
// ^? "green" | "blue"

Each member that matches U becomes never (and never vanishes from a union), so only the non-matching members survive.

“Naked” specifically means only the type parameter, alone, on the checked side. Wrapping it in anything — a tuple, an array, an object — stops distribution, because the compiler is no longer checking the union member-by-member; it’s checking one compound type built from the whole union at once. Three fences, same union, three different outcomes:

// naked T: distributes, one branch per union member
type Naked<T> = T extends string ? "str" : "other";
type N1 = Naked<"a" | 42>;
// ^? "str" | "other" — "a" branch gives "str", 42 branch gives "other", unioned
// [T]: wrapped in a tuple — does NOT distribute, tests the whole union at once
type Wrapped<T> = [T] extends [string] ? "str" : "other";
type W1 = Wrapped<"a" | 42>;
// ^? "other" — the whole union "a" | 42 is not assignable to string, so it's one test, one answer
// T[]: wrapped in an array — also does NOT distribute, for the same reason
type ArrWrapped<T> = T[] extends string[] ? "str" : "other";
type A1 = ArrWrapped<"a" | 42>;
// ^? "other" — ("a" | 42)[] is not assignable to string[] as a whole

Only the first form checks T by itself on the checked side of extends. The other two check a type parameter wrapped inside something else, and wrapping — with a tuple, an array, or anything — is precisely what turns distribution off.

Turning distribution off on purpose: [T] extends [U]

Section titled “Turning distribution off on purpose: [T] extends [U]”

Sometimes you want to test the union as a whole, not member by member — the [T] form from above is also the standard idiom for that:

type IsUnionOfStrings<T> = [T] extends [string] ? true : false;
type D1 = IsUnionOfStrings<"a" | "b">; // true (tested as a whole)
// compare with the distributive version:
type Bad<T> = T extends string ? true : false;
type D2 = Bad<"a" | number>; // boolean (true | false — distributed!)
type D3 = IsUnionOfStrings<"a" | number>; // false (whole union isn't all string)

Reach for [T] extends [U] whenever a conditional over a union gives you a surprising boolean (a sign it distributed and unioned true | false).

[T] extends [never]: the idiom, and why it’s needed

Section titled “[T] extends [never]: the idiom, and why it’s needed”

never deserves special attention because it’s the empty union — a union of zero members. A distributive conditional runs once per member, so distributing over a union with zero members runs zero times, and the result is never itself, not true or false:

type IsNever<T> = T extends never ? true : false;
type R1 = IsNever<never>;
// ^? never — NOT true. Distributing over zero members produces zero branches to union.

This is the single most common surprise with never and conditional types: T extends never ? true : false looks like it should always resolve to a boolean, but for T = never specifically, there’s nothing to distribute over, so the whole expression evaporates into never too. The same [T] wrapping trick that turns off ordinary distribution fixes this the same way:

type IsNeverFixed<T> = [T] extends [never] ? true : false;
type R2 = IsNeverFixed<never>;
// ^? true

Wrapping both sides in a tuple stops the checked type from being naked, so [never] extends [never] is checked as one whole comparison — trivially true — instead of distributing over never’s zero members.

infer in covariant vs. contravariant positions, side by side

Section titled “infer in covariant vs. contravariant positions, side by side”

The Inference & infer lesson showed that repeating an infer name across a covariant (output) position produces a union. Put the same repeated name in a contravariant (parameter) position instead, and it produces an intersection — the two behaviors sit right next to each other in real code:

type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;
// covariant position (a return type): repeated infer produces a UNION
type ReturnOf<T> = T extends { a: () => infer R; b: () => infer R } ? R : never;
type Cov = ReturnOf<{ a: () => string; b: () => number }>;
type _cov = Expect<Equal<Cov, string | number>>;
// contravariant position (a parameter type): repeated infer produces an INTERSECTION
type ParamOf<T> = T extends { a: (x: infer P) => void; b: (x: infer P) => void } ? P : never;
type Contra = ParamOf<{ a: (x: string) => void; b: (x: number) => void }>;
type _contra = Expect<Equal<Contra, string & number>>;

Equal and Expect above are a minimal, local type-level test — compiling with no errors is the proof that both claims hold. (The course has a full lesson on this style of type-level testing later; this is the smallest useful version of it.) The intuition: a value you can read out of either branch has to be something both branches could have produced — a union covers that. A value you must be prepared to hand to either branch has to satisfy both branches’ requirements at once — only an intersection does that. string & number collapses to a type with no real values, which is itself a hint that requiring one value to simultaneously be both a string and a number is normally a sign something upstream is asking for the wrong shape.

The spec for this course flagged this as worth double-checking against the real compiler rather than assuming. Running tsc --help --all against TypeScript 7.0.2 turns up no flag named strictInference — only strict, strictBindCallApply, strictBuiltinIteratorReturn, strictFunctionTypes, strictNullChecks, and strictPropertyInitialization. The “tighter inference” behavior mentioned in the Inference & infer lesson’s “Other 5.9 refinements” section is not something you opt into with a flag — it shipped as an unconditional change to how inference resolves candidates. If you’re looking for a switch to turn that behavior off, there isn’t one in 7.0.2; the fix path when a call breaks after upgrading is the same one described there: supply the type argument explicitly.

Reading a long inferred type from an error message

Section titled “Reading a long inferred type from an error message”

Conditional types compound quickly once they distribute, and the resulting error messages can look intimidating before you notice they’re just listing branches:

// @expect-error TS2322
type ToArray<T> = T extends any ? T[] : never;
declare const value: ToArray<string | number | boolean>;
const bad: string[] = value;
error TS2322: Type 'string[] | number[] | false[] | true[]' is not assignable to type 'string[]'.
Type 'number[]' is not assignable to type 'string[]'.
Type 'number' is not assignable to type 'string'.

Two things worth annotating here. First, the compiler printed the entire distributed result: string[] | number[] | false[] | true[] — one array-of branch for every member ToArray distributed over, in order. That’s not the error; it’s the compiler faithfully showing you what T distributed into before it even got to complaining. Second — a genuine, easy-to-miss TypeScript fact — boolean isn’t one type as far as distribution is concerned: it’s true | false under the hood, so distributing over it produces two branches (false[] and true[]), not one boolean[]. The actual complaint is the second line: it picked one incompatible member (number[]) and reported that against the target string[], then explained that mismatch one level deeper (number vs. string). Reading any long compiler-printed type the same way — top line is the full shape involved, then each nested line narrows to the one real incompatibility — turns an intimidating wall of text into three short, ordinary facts.

Naked distributes, wrapped doesn't — try it yourself

type Naked<T> = T extends string ? "str" : "other";
type Wrapped<T> = [T] extends [string] ? "str" : "other";
type ArrWrapped<T> = T[] extends string[] ? "str" : "other";
type N1 = Naked<"a" | 42>;
// ^? "str" | "other" -- distributes: one branch per union member
type W1 = Wrapped<"a" | 42>;
// ^? "other" -- [T] is not naked -- tests the whole union at once
type A1 = ArrWrapped<"a" | 42>;
// ^? "other" -- T[] is not naked either -- same reason as [T]
// try it: what does Naked<never> give you? (hint: it's the section above this one)
type NakedNever = Naked<never>;
// ^? never -- distributing over zero union members gives zero branches
Open in TS Playground
flowchart TD
  naked["Checked type is T, naked, alone"] --> isUnion{"Argument is a union?"}
  isUnion -->|yes| perMember["Run the conditional once per member
union the branch results together"]
  isUnion -->|no| once["Run the conditional once, normally"]
  wrapped["Checked type is [T], T[], or T wrapped in anything"] --> whole["Test the whole argument as one type -- no distribution, ever"]
Distribution is member-by-member re-running of the whole conditional

Every distributive surprise in this lesson comes from one question: is the checked type parameter naked, or is it wrapped in something? Naked means “re-run this conditional once per union member and union the answers” — which is also why never, the zero-member union, evaporates the whole expression instead of picking a branch. Wrapped means “this is one ordinary type-level comparison,” no different in kind from any non-generic conditional.

ChoiceBenefitCost
Naked T extends U ? X : YAutomatically maps a transformation over every member of a union — the mechanism behind Exclude, Extract, NonNullableSilently does nothing useful for T = never, and can produce much larger types than expected for a wide union
[T] extends [U]Tests the union as one whole; the standard, predictable fix for both “surprising boolean” and the never evaporation caseEasy to forget when you actually wanted per-member behavior somewhere else in the same file
infer in an output position (union result)Natural for “what could this position have produced” questionsA union result can be wider than any single call site actually needs
infer in a parameter position (intersection result)Natural for “what must satisfy every one of these requirements” questionsAn intersection of unrelated primitive types (string & number) collapses to a type with no real values — often a sign of a modeling mistake, not a bug in infer
  • Wrapping a type parameter in [T] changes what values are allowed. It doesn’t touch the value-level type at all — [T] and T describe the same values in every other context. It only changes whether this specific conditional distributes.
  • T extends never ? true : false always evaluates to a boolean. For every concrete, non-never type it does. For T = never itself, distribution runs zero times over the empty union and the whole conditional becomes never, not false.
  • infer always produces a union when a name repeats. Only in covariant (output) positions. The exact same repeated name in a contravariant (parameter) position produces an intersection instead — the position, not the keyword, decides.
  • A flag exists to make TypeScript’s generic inference “stricter,” the way strictNullChecks makes null-checking stricter. As of 7.0.2, tsc --help --all shows no strictInference flag. The 5.9 inference tightening mentioned elsewhere in this module is unconditional behavior, not something toggled by a setting.

Distribution is easy to get backwards in generated utility types — especially ones meant to filter or transform a union — because both the “it silently does nothing” and “it silently distributes when it shouldn’t” failure modes compile with zero errors:

  • A conditional meant to test “is this whole union assignable to X” written as a naked T extends X ? ... : ... will instead test each member separately and union the results — often producing boolean where a single true/false was intended, exactly the tell from the “turning distribution off” section.
  • A conditional meant to filter a union (Exclude-style) written with T wrapped in brackets ([T] extends [U] ? never : T) will not filter anything — it tests the whole union at once and returns all-or-nothing instead of member-by-member.
  • Any conditional utility applied to never deserves a specific check: does the author’s own code (or its tests) ever exercise the never case, or would it silently evaporate to never unnoticed?

🐛 Spot the bug

type Result<T> = { ok: true; value: T } | { ok: false; error: string };
type ExtractValue<T> = [T] extends [{ ok: true; value: infer V }] ? V : never;
type A = ExtractValue<Result<number> | Result<string>>;
Show the answer

This compiles with zero errors, but A is never, not number | string as the name ExtractValue implies. Wrapping the checked type in [T] (presumably copied from the [T] extends [never] idiom or the “turn distribution off” pattern elsewhere in this lesson) turns off exactly the distribution this utility actually needs: with T wrapped, the whole union Result<number> | Result<string> — which includes the { ok: false; error: string } members from both results — is tested against { ok: true; value: infer V } as one piece, fails to match because not every member has an ok: true; value shape, and falls to the never branch. The fix is to remove the brackets and let ExtractValue<T> = T extends { ok: true; value: infer V } ? V : never distribute naturally: each of the four union members is tested individually, the two { ok: false; ... } members correctly become never and drop out, and the two { ok: true; value: ... } members contribute number and string, unioned into the intended number | string.

How do you read T extends U ? X : Y?
What is ToArray<string | number> for type ToArray<T> = T extends any ? T[] : never?
What are N, W, and A?
type Naked<T> = T extends string ? "str" : "other";
type Wrapped<T> = [T] extends [string] ? "str" : "other";
type ArrWrapped<T> = T[] extends string[] ? "str" : "other";

type N = Naked<"a" | 42>;
type W = Wrapped<"a" | 42>;
type A = ArrWrapped<"a" | 42>;
type IsNever<T> = T extends never ? true : false; — what is IsNever<never>, and why?
For repeated infer names, what determines whether the result is a union or an intersection?
Does TypeScript 7.0.2 have a strictInference compiler flag?