Conditional Types

Difficulty: Advanced

Conditional types in TypeScript let you express type-level if/else logic. The syntax `T extends U ? X : Y` reads as: if type `T` is assignable to type `U`, resolve to type `X`; otherwise resolve to type `Y`. This is not a runtime check - it happens entirely in the type system at compile time, allowing you to create types that adapt based on their inputs.

The `infer` keyword is used within conditional types to introduce a type variable that TypeScript will try to infer from the pattern. For example, `T extends Promise<infer R> ? R : T` extracts the resolved type from a Promise. If `T` is `Promise<string>`, then `R` is inferred as `string` and the result is `string`. If `T` is `number`, the condition is false and the result is `number`. This pattern is the foundation of utility types like `ReturnType<T>` and `Parameters<T>`.

Distributive conditional types are a subtle but important behavior. When a conditional type is applied to a naked type parameter that is a union, TypeScript distributes the conditional over each member of the union independently. So `IsString<string | number>` where `IsString<T> = T extends string ? true : false` evaluates to `true | false` (which is `boolean`), not to just `false`. This distribution happens automatically for bare type parameters but not for wrapped ones.

Conditional types are the most powerful feature in TypeScript's type system and enable sophisticated type-level computations. However, they can be difficult to read and debug when deeply nested. Use them judiciously: prefer simpler types when possible, and extract complex conditional types into well-named type aliases with clear documentation.

Code examples

Basic Conditional Type

type IsString<T> = T extends string ? "yes" : "no";

type A = IsString<string>;      // "yes"
type B = IsString<number>;      // "no"
type C = IsString<"hello">;     // "yes"

function check<T>(value: T): IsString<T> {
  return (typeof value === "string" ? "yes" : "no") as IsString<T>;
}

console.log(check("hello"));
console.log(check(42));

IsString conditionally resolves to 'yes' or 'no' based on whether T extends string. The function uses a runtime typeof check that mirrors the type-level logic.

Using infer to Extract Types

type UnwrapPromise<T> = T extends Promise<infer R> ? R : T;
type ElementType<T> = T extends (infer E)[] ? E : T;
type ReturnOf<T> = T extends (...args: any[]) => infer R ? R : never;

// Type-level demonstrations at runtime
function unwrap<T>(value: T): string {
  if (value instanceof Promise) return "Promise unwrapped";
  if (Array.isArray(value)) return `Array of ${value.length} elements`;
  return String(value);
}

console.log(unwrap("hello"));
console.log(unwrap([1, 2, 3]));

// Practical use of ReturnOf
function greet(name: string): string {
  return `Hello, ${name}!`;
}

type GreetReturn = ReturnOf<typeof greet>; // string
const result: GreetReturn = greet("World");
console.log(result);

The `infer` keyword captures type variables from patterns: R from Promise<R>, E from E[], and R from (...) => R. These are the building blocks of advanced type extraction.

Distributive Conditional Types

type NonNullableCustom<T> = T extends null | undefined ? never : T;

type A = NonNullableCustom<string | null | undefined>;
// Distributes: NonNullableCustom<string> | NonNullableCustom<null> | NonNullableCustom<undefined>
// = string | never | never
// = string

function filterNullish<T>(values: T[]): NonNullableCustom<T>[] {
  return values.filter(v => v !== null && v !== undefined) as NonNullableCustom<T>[];
}

const mixed = ["a", null, "b", undefined, "c"];
const clean = filterNullish(mixed);
console.log(clean.join(", "));

The conditional type distributes over the union, removing null and undefined from each member independently. The result type contains only the non-nullish members.

Key points

Concepts covered

extends keyword in types, infer keyword, distributive conditional types, type-level programming