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.
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 + valueThe 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.
| Utility | One-line definition | Mechanism |
|---|---|---|
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 : T | distributive filter |
Extract<T,U> | T extends U ? T : never | distributive 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 : any | infer in conditional |
Parameters<F> | F extends (...a: infer P) => any ? P : never | infer the arg tuple |
InstanceType<C> | C extends new (...a: any) => infer R ? R : any | infer 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.
Why does `Omit<Circle | Square, 'kind'>` (where each has a unique extra key) produce `{}` instead of the per-variant remainder?
Order the evaluation of DeepReadonly<{ a: { b: number } }> from outermost match to the deepest leaf.
- 1 DeepReadonly<{ a: { b: number } }>: not an array, is an object -> map each key
- 2 Key a: DeepReadonly<{ b: number }> -> still an object -> map each key
- 3 Key b: DeepReadonly<number> -> not an array, not an object -> base case returns number
- 4 Inner map yields { readonly b: number }
- 5 Outer map yields { readonly a: { readonly b: number } }
- 01Reconstruct Partial, Required, Readonly, Pick, and Record from scratch, and explain the modifier syntax.
- 02How do ReturnType, Parameters, InstanceType, and Awaited work, and which one is recursive?
- 03Explain why stdlib Omit is non-distributive, what goes wrong on a discriminated union, and the exact DistributiveOmit fix.
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.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.