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.
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.
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.
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.
extends keyword in types, infer keyword, distributive conditional types, type-level programming