Skip to content
academia.sh

Lesson 15 / 23

Conditional Types

Type-level branching, the distribution rule over unions and closing off distribution, extracting a type piece with infer, and the limit of conditional return types.

Contents

The previous lesson used a notation of the form T[A] extends number ? A : never and branched at the type level. A conditional type is the structure at the center of the type system’s computing ability: it picks one of two types depending on whether one type is assignable to another.

Its notation is the type-level counterpart of the ternary operator:

T extends U  ?  X:YT \text{ extends } U \;?\; X : Y

This lesson covers how the condition is evaluated, its special behavior over unions, extracting a piece with infer, and the limits of the structure.

Branching at the Type Level

type IsNumber<T> = T extends number ? "number" : "not a number";

const a: IsNumber<number> = "number";
const b: IsNumber<string> = "not a number";
console.log(a, b);

const c: IsNumber<number> = "not a number";
p1.ts(7,7): error TS2322: Type '"not a number"' is not assignable to type '"number"'.

IsNumber<number> resolves to "number", IsNumber<string> to "not a number". The seventh line’s diagnostic shows the resolution really happened: the compiler reports the error by writing "number" in place of IsNumber<number>.

The word extends here does not mean inheritance but an assignability test: “can T be put where U is expected?” The rules established in the Structural Type Compatibility lesson apply here.

Distribution

If a conditional type’s type parameter is a union, the condition is evaluated separately for each member and the results are combined into a union. This is called distribution:

type IsNumber<T> = T extends number ? "number" : "not a number";
type NonDistributive<T> = [T] extends [number] ? "number" : "not a number";

declare const distributed: IsNumber<number | string>;
declare const sealed: NonDistributive<number | string>;

const x: null = distributed;
const y: null = sealed;
p2.ts(7,7): error TS2322: Type '"not a number" | "number"' is not assignable to type 'null'.
  Type '"not a number"' is not assignable to type 'null'.
p2.ts(8,7): error TS2322: Type '"not a number"' is not assignable to type 'null'.

The diagnostics spell out both types explicitly. IsNumber<number | string> has become "not a number" | "number"; the condition was evaluated first for number, then for string. NonDistributive<number | string> gave a single result: "not a number".

What closes off distribution is wrapping the type parameter in a tuple. In the notation [T] extends [U], what is being tested is no longer T but a single-element tuple; it does not split into union members, and the condition is evaluated once.

Which of the two behaviors is wanted depends on intent. If every member of a union needs to be transformed, distribution is needed. If the question being asked is “does all of this type satisfy this condition?”, it should be closed off — otherwise a meaningless result like “partly a number” comes out for the number | string union.

Distribution only applies to bare type parameters: the left side of the condition must be directly T. There is no distribution in notations like T[], [T], or { value: T }.

Filtering: Drop

Distribution’s most common use is eliminating a member from a union. never is produced for the member to be filtered; never takes up no space in a union:

type Drop<T, U> = T extends U ? never : T;

type Unit = "C" | "Pa" | "%";
type MeasurableUnit = Drop<Unit, "%">;

const units: MeasurableUnit[] = ["C", "Pa"];
console.log(units.join(","));

The output is C,Pa. If the line const wrong: MeasurableUnit = "%"; is added at the end of the file, after a blank line:

p5.ts(9,7): error TS2322: Type '"%"' is not assignable to type 'MeasurableUnit'.

The steps of the computation are as follows: the condition is evaluated separately for the three members; "C" and "Pa" are not assignable to "%" so they produce themselves, and "%" produces never. The result is "C" | "Pa" | never, which is "C" | "Pa".

This transform’s standard-library counterpart is Exclude, covered in the Utility Types lesson.

Extracting a Piece with infer

A type variable can be declared on the left side of the condition with the infer keyword. During matching, the compiler finds the type corresponding to that variable:

interface Measurement {
  id: string;
  value: number;
}

type ElementType<T> = T extends readonly (infer E)[] ? E : never;

declare const element: ElementType<Measurement[]>;
declare const stringElement: ElementType<readonly string[]>;
declare const nonArray: ElementType<number>;

const a: null = element;
const b: null = stringElement;
const c: null = nonArray;
p3.ts(12,7): error TS2322: Type 'Measurement' is not assignable to type 'null'.
p3.ts(13,7): error TS2322: Type 'string' is not assignable to type 'null'.

ElementType<Measurement[]> has become Measurement, ElementType<readonly string[]> has become string. The fourteenth line producing no diagnostic also carries information: ElementType<number> is never, and never is assignable to every type — including null.

The same technique is used to extract a payload type from a course-wide result type:

interface Measurement {
  id: string;
  value: number;
}

type Result<D, E = string> =
  | { status: "success"; value: D }
  | { status: "error"; error: E };

type SuccessType<S> = S extends { status: "success"; value: infer D } ? D : never;
type ErrorType<S> = S extends { status: "error"; error: infer E } ? E : never;

declare const success: SuccessType<Result<Measurement>>;
declare const error: ErrorType<Result<Measurement, number>>;

const a: null = success;
const b: null = error;
p4.ts(16,7): error TS2322: Type 'Measurement' is not assignable to type 'null'.
p4.ts(17,7): error TS2322: Type 'number' is not assignable to type 'null'.

Distribution is at work here too: Result<Measurement> is a two-member union; computing SuccessType gives Measurement for the success member, never for the error member, and the union reduces to Measurement.

The value of this pattern is being able to reach into a piece of a type without repeating its definition. If Result’s structure changes, SuccessType changes with it; no manually maintained link remains between the two.

The Limit of Conditional Return Types

Conditional types can be used in function signatures, but they do not resolve inside the body:

type Formatted<T> = T extends number ? string : number;

function format<T extends number | boolean>(value: T): Formatted<T> {
  if (typeof value === "number") {
    return value.toFixed(1);
  }
  return 0;
}

console.log(format(21.4), format(true));
p7.ts(5,5): error TS2322: Type 'string' is not assignable to type 'Formatted<T>'.
p7.ts(7,3): error TS2322: Type 'number' is not assignable to type 'Formatted<T>'.

Inside the body, Formatted<T> cannot be resolved because T is not yet known; the compiler cannot infer that the typeof value test also narrows T. The diagnostic comes on both branches.

This is a known limit of the type system. There are two fixes: using a single type assertion in the body — a debt that makes the limit visible and accepts that the body honors the promise made in the signature — or writing an overload instead of a conditional type. The second is the structure built in the Function Types lesson, and gives callers the same result.

General principle: conditional types are powerful for computing a type, not for driving an implementation.

Summary

  • A conditional type picks one of two types based on an assignability test; extends here means assignability, not inheritance.
  • If a bare type parameter is a union, the condition is evaluated separately for each member and the results are combined into a union; the notation [T] extends [U] closes off this distribution.
  • Distribution together with never builds filters that eliminate a member from a union.
  • infer declares a type variable on the left side of the condition and extracts the type corresponding to it during matching; it lets a piece of a type be reached without repeating its definition.
  • Conditional return types do not resolve inside the function body; this case needs either a type assertion or an overload.

Next Step

The key remapping lesson used a notation of the form `read${Capitalize<string & A>}` and computed a field name. Combining and parsing strings at the type level is a separate toolset. The next lesson covers template literal types and how they are used together with infer to parse string patterns.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close