if-then-else at the type level
a conditional type is a ternary that runs in the type system instead of
at runtime. the syntax mirrors a ternary expression — T extends U
? X : Y — and the semantics are simple: if T is
assignable to U, the result is X, otherwise
Y. that one construct is the doorway to almost every
interesting type the standard library ships.
// "if a type is X then Y else Z"
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<"hello">; // "yes"
type B = IsString<42>; // "no"
type C = IsString<string>; // "yes"
on its own, IsString is a curiosity. the value comes from
what happens next: composing conditionals with unions, with
infer, and with recursion. every utility type you've
used — ReturnType, Parameters,
Awaited, NonNullable — is a conditional
type with one or two embellishments.
distribution over unions
when the checked type is a "naked" type parameter (no brackets, not wrapped), a conditional distributes over every member of a union. this is usually what you want — but knowing it's happening is the difference between a working type and a baffling result.
type ToArray<T> = T extends any ? T[] : never;
// you might expect string[] | number[]:
type Mixed = ToArray<string | number>;
// ^? string[] | number[] ← distributes over the union
// to opt out, wrap in [] on both sides:
type ToArrayJoint<T> = [T] extends [any] ? T[] : never;
type Joint = ToArrayJoint<string | number>;
// ^? (string | number)[] [T] extends [U] — on both sides
to opt out. you'll need this once a year, and on that day you'll be
very glad you remember it.
the infer keyword
infer is pattern matching at the type level. you put
infer Name wherever you want the compiler to bind a
sub-type, and you get to use Name on the right side of
the conditional. that's it. that's the whole feature. but it's the
feature that lets the type system extract parts of types
instead of just compare whole ones.
// infer extracts a sub-type by pattern matching.
type ElementOf<T> = T extends (infer E)[] ? E : never;
type S = ElementOf<string[]>; // string
type N = ElementOf<number[]>; // number
type X = ElementOf<{ a: 1 }>; // never (not an array)
// infer multiple parts at once:
type Pair<T> = T extends [infer A, infer B] ? { first: A; second: B } : never;
type P = Pair<[string, number]>;
// ^? { first: string; second: number } ReturnType from scratch
one of the most-used utility types in real codebases, and it's
eight tokens of conditional + infer. when a function
type matches the shape (...args: any[]) => R, bind
the return type as R; otherwise yield never.
// the standard-library version, written from scratch:
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() {
return { id: "u_1", name: "ada" };
}
type User = ReturnType<typeof getUser>;
// ^? { id: string; name: string } Awaited from scratch
one step harder: recursive. a promise can resolve to a promise can resolve to a value, and you want the value out. the type recurses on itself until the conditional bottoms out at a non-promise.
// Awaited<T> unwraps a promise (recursively).
type Awaited<T> = T extends Promise<infer U>
? Awaited<U> // recurse — promises can nest
: T extends { then(onFulfilled: infer F): unknown }
? F extends (value: infer V, ...args: any) => unknown
? Awaited<V>
: never
: T;
type A = Awaited<Promise<string>>; // string
type B = Awaited<Promise<Promise<number>>>; // number
type C = Awaited<string>; // string (passes through)
the second branch is the part that catches thenables that aren't
real Promises — the api the spec actually requires.
it's the same logic typescript's standard lib uses, and it's
worth reading once for the shape of "extract a value type out of
a thenable's signature".
check yourself
conditional types — 4 questions
evaluate the conditional in your head, then pick. these are the patterns the standard library leans on.
type Flag<T> = T extends true ? "on" : "off";
type X = Flag<true>;
// ^?
type Y = Flag<false>;
// ^?what are X and Y?
type Wrap<T> = T extends any ? T[] : never;
type Z = Wrap<string | number>;
// ^?what is Z?
type First<T> = T extends [infer F, ...any[]] ? F : never;
type A = First<[string, number, boolean]>;
// ^?
type B = First<[]>;
// ^?what are A and B?
type Unwrap<T> = T extends Promise<infer U> ? Unwrap<U> : T;
type R = Unwrap<Promise<Promise<Promise<string>>>>;
// ^?what is R?
a recursive helper: DeepPartial
Partial<T> makes every top-level field optional. a
common need: make every field optional at every depth — for
config patches, test fixtures, draft state. conditional + mapped +
recursion gets you there in three lines.
// DeepPartial — Partial but recursive.
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
interface Config {
server: { host: string; port: number };
features: { ai: boolean; experimental: { beta: boolean } };
}
type ConfigPatch = DeepPartial<Config>;
// ^? {
// server?: { host?: string; port?: number };
// features?: {
// ai?: boolean;
// experimental?: { beta?: boolean };
// };
// } three patterns side by side
three uses of conditional + infer
the same primitive — match-and-extract — solving three different problems.
type Parameters<T> =
T extends (...a: infer A) => any ? A : never;
function send(to: string, body: object): void {}
type SendArgs = Parameters<typeof send>;
// ^? [to: string, body: object]// SendArgs = [to: string, body: object]
//
// useful for forwarding:
// function wrap(...a: SendArgs) { return send(...a); }lets you forward args without re-typing the function signature.
type Awaited<T> =
T extends Promise<infer U> ? Awaited<U> : T;
async function fetchUser() {
return { id: "u_1" };
}
type User = Awaited<ReturnType<typeof fetchUser>>;
// ^? { id: string }// User = { id: string }
//
// the standard library's Awaited<T>
// also handles non-Promise thenables;
// this is the shape of the idea.the return-types of async functions are wrapped Promises. Awaited lets you talk about the resolved value.
type NonNullable<T> = T extends null | undefined ? never : T;
type A = NonNullable<string | null>;
// ^?
type B = NonNullable<number | undefined | null>;
// ^?// A = string
// B = number
//
// the distribution does the work:
// each union member is checked
// separately, nulls map to never,
// never disappears from unions.the distributive behavior makes this a one-liner. without distribution you'd need to wrap with brackets and write more logic.
when not to reach for it
conditional types are powerful and that's the warning. if you find yourself nesting three levels of conditionals to compute a return shape, step back: you're probably solving the wrong problem at the type level. the rule is simple — when a conditional type's purpose can be served by an overload or a function, prefer the simpler tool. types should make code clearer. when a type is harder to read than the code it describes, it's a regression.
what you took away
-
a conditional is an
extends ? :ternary that runs in the type system. it's the basis of every interesting utility type. - naked type parameters distribute over unions. opt out by wrapping in a 1-tuple on both sides.
-
infer Xpattern-matches and binds a sub-type. use it to pull the return type, arguments, promise value, element type, whatever — out of a larger shape. -
conditionals can recurse on themselves to walk deep into a type.
AwaitedandDeepPartialare textbook examples. - respect the budget. if a runtime function or an overload is simpler than the conditional, take the simpler tool.
further reading
- typescript handbook · conditional types — the spec; covers infer and distribution.
- TypeScript/src/lib/es5.d.ts — read the standard library; every utility type is a one-liner.