Frontend / Typescript / 02_conditional_and_mapped_types.md

Conditional and Mapped Types (and infer)

Updated 5 min read source
On this page14
  • What’s a conditional type?
  • What’s a distributive conditional type?
  • How do you disable distribution?
  • What does infer do?
  • What’s a mapped type?
  • What modifiers can you apply in a mapped type?
  • What’s key remapping with as?
  • Build DeepReadonly.
  • When does distribution bite you?
  • When does a recursive type explode?
  • Gotchas
  • Related
  • Further reading
  • Interview angle
  • Conditional and Mapped Types (and infer)

    Conditional types, mapped types, and infer together form TypeScript’s expression layer — the part of the type system that computes new types from existing ones. Every utility type (Partial, Pick, ReturnType, etc.) is built from these three primitives. Senior-level knowledge: when conditionals distribute over unions (and how to disable it with [T]), how infer extracts types from positions, and how as in a mapped type lets you remap or filter keys.

    What’s a conditional type?

    T extends U ? A : B — at the type level. Resolved structurally.

    ts
    type IsString<T> = T extends string ? true : false
    
    type A = IsString<'hello'>  // true
    type B = IsString<42>       // false

    Conditionals get powerful when combined with generics and infer.

    What’s a distributive conditional type?

    When the checked type is a naked generic parameter, the conditional distributes over a union — it’s applied to each member separately and the result unioned.

    ts
    type ToArray<T> = T extends any ? T[] : never
    
    type R = ToArray<string | number>
    // = (string extends any ? string[] : never) | (number extends any ? number[] : never)
    // = string[] | number[]      ← distributed
    // NOT (string | number)[]

    Useful for “map each member of a union.” Sometimes unwanted — see next.

    How do you disable distribution?

    Wrap the checked type in a tuple [T]:

    ts
    type ToArrayNoDist<T> = [T] extends [any] ? T[] : never
    
    type R = ToArrayNoDist<string | number>
    // = (string | number)[]    ← NOT distributed

    This is how built-in Exclude would behave differently if it didn’t distribute (which is exactly why Exclude does distribute).

    What does infer do?

    infer X introduces a fresh type variable inside a conditional type and binds it to whatever shape matches. It’s the type-level equivalent of pattern matching.

    ts
    type ReturnT<F> = F extends (...args: any[]) => infer R ? R : never
    
    type A = ReturnT<() => number>                 // number
    type B = ReturnT<(x: string) => boolean>       // boolean
    type C = ReturnT<string>                       // never

    You can infer from arrays, promises, tuples, function parameters — any structural position.

    ts
    type ElementOf<T> = T extends (infer E)[] ? E : never
    type Awaited1<T>  = T extends Promise<infer V> ? V : T
    type FirstParam<F> = F extends (a: infer A, ...rest: any[]) => any ? A : never

    What’s a mapped type?

    A mapped type iterates over a key set and produces a new object type.

    ts
    type Stringify<T> = { [K in keyof T]: string }
    
    interface User { id: number; name: string; active: boolean }
    type S = Stringify<User>
    // = { id: string; name: string; active: string }

    The K in keyof T syntax is the iteration.

    What modifiers can you apply in a mapped type?

    readonly and ? (optional), each prefixable with + or - to add or remove:

    ts
    type MyPartial<T>   = { [K in keyof T]?: T[K] }                  // adds ?
    type MyReadonly<T>  = { readonly [K in keyof T]: T[K] }          // adds readonly
    type MyMutable<T>   = { -readonly [K in keyof T]: T[K] }         // removes readonly
    type MyRequired<T>  = { [K in keyof T]-?: T[K] }                 // removes ?

    -? is how Required is implemented.

    What’s key remapping with as?

    A mapped type can rewrite its key with as NewKey. Combined with template literal types, this lets you transform property names:

    ts
    type Getters<T> = {
      [K in keyof T as `get${Capitalize<K & string>}`]: () => T[K]
    }
    
    interface User { id: number; name: string }
    type UserGetters = Getters<User>
    // = { getId: () => number; getName: () => string }

    And as never filters a key out — used to build Omit-style types:

    ts
    type StripFunctions<T> = {
      [K in keyof T as T[K] extends Function ? never : K]: T[K]
    }

    Build DeepReadonly<T>.

    ts
    type DeepReadonly<T> =
      T extends (...args: any[]) => any ? T :
      T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } :
      T

    Three branches: skip functions (don’t add readonly to a function), recurse into objects, leave primitives alone.

    When does distribution bite you?

    When you want a conditional about a union rather than over each member. Compare:

    ts
    // Distributes — checks each union member.
    type NonNullable1<T> = T extends null | undefined ? never : T
    
    // Doesn't distribute — checks the whole union.
    // not actually useful as-is
    type IsUnion<T> = [T] extends [infer U] ? U : never
    
    // Classic "did we get a union" detector — uses distribution intentionally:
    type UnionToIntersection<U> =
      (U extends any ? (x: U) => void : never) extends ((x: infer I) => void) ? I : never

    If T is a naked parameter, it distributes; if wrapped in [T] or another constructor, it doesn’t.

    When does a recursive type explode?

    Mapped/conditional types that recurse into themselves on deeply nested input can hit the TypeScript instantiation depth limit (~50–100). You’ll see Type instantiation is excessively deep and possibly infinite. The fix is usually to bound recursion or simplify the shape.

    ts
    // Risky on big inputs:
    type DeepKeys<T> = T extends object
      ? { [K in keyof T]: K | DeepKeys<T[K]> }[keyof T]
      : never

    Gotchas

    • Distribution requires a naked type parameter — T extends ... distributes; { x: T } extends ... does not.
    • never is the empty union — never extends X ? A : B is never (vacuous distribution).
    • infer binding scope — bound only inside that one branch; can’t reuse it outside.
    • Mapped over union of object types — { [K in keyof (A | B)]: ... } uses only the shared keys; usually not what you wanted.
    • Recursive type-instantiation limit — TS won’t dive infinitely; use T extends object guards and bounded depth where possible.
    • { [K in keyof T]: T[K] } is structurally identical to T — but the act of mapping can lose modifiers; reapply them with +readonly/+? if needed.

    Further reading

    Interview angle

    A junior calls Partial<T> and Pick<T, K> “TypeScript built-ins.” A senior knows they’re trivial mapped types built from keyof T + [K in ...] + the ? / readonly modifiers — and can reimplement them on the spot. A senior also names distribution as the property that makes Exclude/Extract/NonNullable work, knows how to disable it with [T] extends [U], and uses infer for extracting types from positions (return type, promise value, array element, function parameter).