scalable/craft/ts types in anger/lesson 04 cph / /
lesson 04 / 06 · 14 min · updated ·

conditional types & infer

type-level branching. Awaited and ReturnType from scratch. when to reach for it and when not to.

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.

the basic shape
// "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.

distribution
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)[]
rule a conditional with a naked type parameter distributes. wrap the parameter in a 1-tuple — [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 in one line
// 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.

rebuilding ReturnType
// 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.

rebuilding Awaited
// 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.

simple conditional
type Flag<T> = T extends true ? "on" : "off";
type X = Flag<true>;
//    ^?
type Y = Flag<false>;
//    ^?

what are X and Y?

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.

recursive partial
// 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]
tsc sees
// 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.

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.

tip when a conditional type starts feeling like a chess puzzle, use the typescript playground to verify the inferred result. it's much cheaper to confirm at the source than to debug a confusing downstream type error.

what you took away

further reading

scalable labs·cvr 30091604·github·linkedin·hello@scalable.dk