Master the distributivity of conditional types, pattern matching with infer, and edge cases like never, any, and boolean.
Open this lesson in KodokonConditional types are the branching mechanism of the type system: T extends U ? X : Y asks the compiler whether T is assignable to U, then selects a branch. All the standard utilities such as ReturnType, Exclude, or Awaited are built on them. At this level, the question is no longer how to write them, but how to predict exactly how the compiler evaluates them, especially when unions and never are involved.
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">;
// true
type B = IsString<string | number>;
// boolean, not false!The key behavior is distributivity: when the tested type is a naked type parameter (not wrapped in a tuple, an array, or an intersection), the conditional is applied separately to each member of the union, and the results are then combined. IsString<string | number> therefore computes true | false, which is boolean. To disable this distribution, wrap both sides in a tuple: [T] extends [U]. One fearsome edge case: never is the empty union; distributing over zero members evaluates no branch at all, and the result is never no matter which branch you wrote.
type ToArray<T> = T extends unknown ? T[] : never;
type NoDist<T> = [T] extends [unknown] ? T[] : never;
type A = ToArray<string | number>;
// string[] | number[]
type B = NoDist<string | number>;
// (string | number)[]
type IsNever<T> = [T] extends [never] ? true : false;
type C = IsNever<never>; // true thanks to the tupleThe infer keyword introduces a type variable captured through pattern matching: the compiler tries to match the structure of the tested type against the pattern, and binds the variable if the match succeeds. You can place several infer declarations with the same name: in a covariant position (produced values, such as a return type), the candidates are merged into a union; in a contravariant position (function parameters), they are combined into an intersection. This rule follows directly from variance: it is the only safe choice in each direction.
type ElementOf<T> =
T extends readonly (infer E)[] ? E : never;
type FirstParam<T> =
T extends (arg: infer P) => unknown ? P : never;
type Merged<T> =
T extends { a: (x: infer P) => void;
b: (x: infer P) => void }
? P : never;
type M = Merged<{
a: (x: { id: string }) => void;
b: (x: { tag: string }) => void;
}>; // { id: string } & { tag: string }Since TypeScript 4.7, infer accepts an inline constraint: infer N extends number filters the capture, and since 4.8 the compiler even converts a numeric string literal into a number literal. Another specification subtlety: a conditional type whose input still contains an unresolved generic parameter stays deferred; it is only evaluated at the instantiation site, which explains why some errors only show up on the caller's side.
type ToNumber<S extends string> =
S extends `${infer N extends number}` ? N : never;
type Port = ToNumber<"8080">; // the literal 8080
type Bad = ToNumber<"abc">; // neverExclude<boolean, true>?type IsNever<T> = T extends never ? true : false;, what is IsNever<never>?infer P with the same name placed in function parameter position produce: