open atlas
↑ Back to track
TypeScript type system, deep TS · 05 · 03

Utility types from scratch

Every standard utility type is a one-line mapped or conditional type — reimplementing Partial, Pick, Omit, ReturnType, Awaited and friends demystifies them and reveals why stdlib Omit is non-distributive.

TS Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

You reach for Omit<Shape, "kind"> on a discriminated union Circle | Square and the result is wrong: instead of “each variant minus its kind”, you get an object with only the keys common to all variants — the radius and the side length are gone. You stare at it, certain the stdlib is broken. It is not. Omit is defined as Pick<T, Exclude<keyof T, K>>, and keyof (Circle | Square) is only the shared keys. Once you have written Omit yourself, this stops being a mystery and becomes a one-line fix. Every utility type in lib.es5.d.ts is a mapped or conditional type you can read, rebuild, and out-grow.

The mapped-type family

You have used Partial, Required, and Readonly a hundred times — but could you write them from scratch? Once you can, you stop treating the standard library as a black box and start reaching into it when it doesn’t fit. Partial, Required, and Readonly are mapped types with a modifier; Pick and Record choose the key set:

type MyPartial<T>  = { [P in keyof T]?: T[P] };          // add ? to every key
type MyRequired<T> = { [P in keyof T]-?: T[P] };          // -? removes optionality
type MyReadonly<T> = { readonly [P in keyof T]: T[P] };   // add readonly
type MyPick<T, K extends keyof T> = { [P in K]: T[P] };   // map over a key subset
type MyRecord<K extends keyof any, V> = { [P in K]: V };  // build from keys + value

The modifier syntax is the whole story: ? and readonly add a modifier; -? and -readonly strip one. keyof any is string | number | symbol — the legal key space for Record.

UtilityOne-line definitionMechanism
Partial<T>{ [P in keyof T]?: T[P] }mapped + optional modifier
Required<T>{ [P in keyof T]-?: T[P] }mapped + strip optional
Readonly<T>{ readonly [P in keyof T]: T[P] }mapped + readonly
Pick<T,K>{ [P in K]: T[P] }map over key subset
Record<K,V>{ [P in K]: V }map keys to one value
Exclude<T,U>T extends U ? never : Tdistributive filter
Extract<T,U>T extends U ? T : neverdistributive keep
NonNullable<T>T & {}intersect to drop null/undefined (TS 4.8+)
Omit<T,K>Pick<T, Exclude<keyof T, K>>pick the complement — NON-distributive
ReturnType<F>F extends (...a: any) => infer R ? R : anyinfer in conditional
Parameters<F>F extends (...a: infer P) => any ? P : neverinfer the arg tuple
InstanceType<C>C extends new (...a: any) => infer R ? R : anyinfer construct return

The conditional family: infer the interesting part

ReturnType, Parameters, InstanceType, and Awaited all use infer to pull a piece out of a structural shape:

type MyReturnType<F> = F extends (...args: any) => infer R ? R : any;
type MyParameters<F> = F extends (...args: infer P) => any ? P : never;
type MyInstanceType<C> = C extends new (...args: any) => infer R ? R : any;

type R1 = MyReturnType<() => number>;          // ^? type R1 = number
type P1 = MyParameters<(a: string, b: number) => void>; // ^? type P1 = [a: string, b: number]

Awaited is the recursive one — it unwraps nested promises and .then-ables down to the final value:

type MyAwaited<T> =
  T extends null | undefined ? T :
  T extends object & { then(onfulfilled: infer F, ...args: any): any }
    ? F extends (value: infer V, ...args: any) => any ? MyAwaited<V> : never
    : T;

type A1 = MyAwaited<Promise<Promise<string>>>;
//   ^? type A1 = string   (recursion unwraps both layers)
Why this works

Why is NonNullable<T> defined as T & {} and not T extends null | undefined ? never : T? Both work, but T & {} (TS 4.8+) preserves more information: {} is “any non-null, non-undefined value”, and intersecting with it removes exactly null and undefined while leaving generic and conditional types un-eagerly-evaluated. The conditional form forces distribution and can prematurely collapse a still-generic T. The intersection is the modern, non-distributive definition the stdlib ships.

Composing: DeepPartial and DeepReadonly

Recurse the mapped type into nested objects, with a conditional base case for non-objects:

type DeepReadonly<T> = T extends (infer E)[]
  ? ReadonlyArray<DeepReadonly<E>>
  : T extends object
    ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
    : T;

type DeepPartial<T> = T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> }
  : T;

type Config = { db: { host: string; pool: { max: number } } };
type DC = DeepPartial<Config>;
//   ^? every level optional: { db?: { host?: string; pool?: { max?: number } } }

The base case is “not an object — return it unchanged”; the recursive case re-maps each property through the same deep transform.

The Omit trap — and DistributiveOmit

Here is the discriminated-union surprise from the Hook. Omit<T, K> = Pick<T, Exclude<keyof T, K>>. When T is a union, keyof T collapses to only the keys present in every member:

type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; side: number };
type Shape = Circle | Square;

type Bad = Omit<Shape, "kind">;
//   ^? type Bad = {}   (keyof Shape is only "kind"; removing it leaves nothing)

// Force distribution so Omit runs per-member:
type DistributiveOmit<T, K extends keyof any> =
  T extends unknown ? Omit<T, K> : never;

type Good = DistributiveOmit<Shape, "kind">;
//   ^? type Good = { radius: number } | { side: number }

Omit is intentionally non-distributive — it operates on T as a whole, so keyof (Circle | Square) is just "kind" (the intersection of key sets). Wrapping it in a naked-parameter conditional (T extends unknown ? Omit<T, K> : never) forces distribution: now Omit runs once per variant and you get the per-variant result you expected. This single pattern is the most common reason people think Omit is broken on unions.

Quiz

Why does `Omit<Circle | Square, 'kind'>` (where each has a unique extra key) produce `{}` instead of the per-variant remainder?

Order the steps

Order the evaluation of DeepReadonly<{ a: { b: number } }> from outermost match to the deepest leaf.

  1. 1 DeepReadonly<{ a: { b: number } }>: not an array, is an object -> map each key
  2. 2 Key a: DeepReadonly<{ b: number }> -> still an object -> map each key
  3. 3 Key b: DeepReadonly<number> -> not an array, not an object -> base case returns number
  4. 4 Inner map yields { readonly b: number }
  5. 5 Outer map yields { readonly a: { readonly b: number } }
Recall before you leave
  1. 01
    Reconstruct Partial, Required, Readonly, Pick, and Record from scratch, and explain the modifier syntax.
  2. 02
    How do ReturnType, Parameters, InstanceType, and Awaited work, and which one is recursive?
  3. 03
    Explain why stdlib Omit is non-distributive, what goes wrong on a discriminated union, and the exact DistributiveOmit fix.
Recap

The standard library is not magic: Partial/Required/Readonly/Pick/Record are mapped types with a modifier; Exclude/Extract are distributive conditionals; ReturnType/Parameters/InstanceType infer a piece of a callable; NonNullable is T & {}; Awaited recurses to unwrap promises. Compose them recursively for DeepPartial and DeepReadonly. The one trap to remember is that Omit is Pick<T, Exclude<keyof T, K>> and therefore non-distributive — on a union it keeps only shared keys, so reach for DistributiveOmit (T extends unknown ? Omit<T, K> : never) when you need per-variant behavior. Now when you see Omit produce an empty object on a discriminated union, you know exactly why — and the one-line fix.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.