Mapped types: iterate a type's keys to build a new one
A mapped type `{ [K in keyof T]: ... }` iterates T's keys to transform values, with `?`/`-?` and `readonly`/`-readonly` modifiers. The crucial subtlety is homomorphism: mapping over `keyof T` preserves modifiers and array/tuple-ness; mapping a separate key union does not.
You pass a config object to a library and it accepts Partial<Config> — every field suddenly optional, without anyone writing a second interface. You hover Partial and find { [P in keyof T]?: T[P] }. One line generated an entire optional twin of your type. Partial, Required, Readonly, Pick, Record — almost every utility type in lib.es5.d.ts is a mapped type: a loop over keys that rebuilds the object, member by member.
In this lesson you’ll write every one of those utility types from scratch and discover the subtle homomorphism rule that determines whether your mapped type preserves arrays — or silently flattens them into plain objects.
Iterating keys with K in keyof T
Ask yourself: how does Partial<Config> know to make every field optional without listing them? Because mapped types don’t know the keys ahead of time — they compute them at instantiation. That’s the power, and it’s why adding a field to Config automatically makes it optional in Partial<Config> with no extra work.
A mapped type looks like an index signature, but in makes it a loop: for each key K in the union keyof T, emit a property. Inside, T[K] is the original value type at that key.
type Stringify<T> = { [K in keyof T]: string };
type Config = { port: number; host: string; tls: boolean };
type S = Stringify<Config>;
// ^? { port: string; host: string; tls: string }keyof Config is "port" | "host" | "tls"; the mapped type iterates that union and sets every value to string. To keep the original value type, reference T[K]:
type Clone<T> = { [K in keyof T]: T[K] };
type C = Clone<Config>;
// ^? { port: number; host: string; tls: boolean }Modifiers: add or strip ? and readonly
A mapped type can attach or remove the two property modifiers. ? makes optional, -? removes optionality; readonly adds it, -readonly removes it. This is how the standard utilities are built — here they are from scratch:
type MyPartial<T> = { [K in keyof T]?: T[K] }; // add ?
type MyRequired<T> = { [K in keyof T]-?: T[K] }; // strip ?
type MyReadonly<T> = { readonly [K in keyof T]: T[K] }; // add readonly
type MyMutable<T> = { -readonly [K in keyof T]: T[K] }; // strip readonly
type Src = { a?: number; readonly b: string };
type P = MyPartial<Src>; // ^? { a?: number; readonly b?: string }
type R = MyRequired<Src>; // ^? { a: number; readonly b: string }
type M = MyMutable<Src>; // ^? { a?: number; b: string }Note MyRequired<Src> turns a? into a required a: number (the ? is stripped) but leaves readonly b readonly — -? touches only optionality. MyMutable strips the readonly from b but leaves a optional. The modifiers are orthogonal.
Homomorphic vs non-homomorphic — the subtle, crucial difference
When a mapped type is written { [K in keyof T]: ... } — iterating directly over keyof T of a single type parameter — it is homomorphic. Homomorphic mapped types do two quiet but important things: they preserve the original modifiers (so Clone above kept readonly b), and they preserve array- and tuple-ness:
type Clone<T> = { [K in keyof T]: T[K] }; // homomorphic
type CA = Clone<number[]>; // ^? number[] — still an array
type CT = Clone<[1, 2]>; // ^? [1, 2] — still a tuple
type CR = Clone<readonly string[]>; // ^? readonly string[] — readonly keptNow compare a mapped type that iterates over a key union it received as a separate parameter — non-homomorphic:
type FromKeys<K extends PropertyKey, V> = { [P in K]: V };
type Obj = FromKeys<"a" | "b", number>;
// ^? { a: number; b: number } — fine for object keys
type Broken = FromKeys<keyof number[], unknown>;
// ^? a plain object with number[]'s string keys ("length", "push", ...) — NOT an arrayFromKeys maps over a bare key union K, not over keyof T, so TypeScript has no original container to preserve — the result is always a plain object. This is exactly why Record<K, V> = { [P in K]: V } produces objects, while Partial/Readonly (written over keyof T) faithfully preserve arrays and tuples.
▸Why this works
TypeScript detects homomorphism syntactically: the mapped type’s constraint must be exactly keyof T for a single type parameter T (or T itself when T is constrained to a key type). When it sees that shape, it keeps a link to the source T so it can copy T’s modifiers per key and special-case arrays/tuples — mapping over each element type and rebuilding the same container. Lose that exact keyof T shape — by routing the keys through another parameter or a remap (next lesson) — and the link is gone, modifiers are dropped, and arrays collapse to plain objects.
The cost side: mapped types scale with your object’s width
A mapped type instantiates one property per key, so its cost is linear in the key count — usually negligible. The blow-up comes from composition: Partial<DeepReadonly<Pick<T, ...>>> over a 200-field generated type re-materialises a full object at each layer, and stacked over a union of such types the property count multiplies. The hard ceiling is the same union/object limits as everywhere else — a mapped type whose value expression distributes a conditional over a large union can hit the 100,000-member Expression produces a union type that is too complex to represent wall.
The senior breakpoint: a mapped type earns its keep when the transform must apply uniformly across an unknown or evolving set of keys — Partial<Config> stays correct when someone adds a field. It is over-engineering when the object is small and fixed: hand-writing the optional twin is two extra lines a reader understands instantly, where the mapped type forces them to mentally expand it. And the modifier/homomorphism rules are a real maintenance tax — the next engineer must know that routing keys through as or a separate parameter silently drops readonly and collapses arrays (the bugs in this lesson). When the shape is generated from a schema anyway (Prisma, GraphQL, OpenAPI), prefer the codegen output’s flat interface over wrapping it in three layers of mapped utilities; the generated interface is hoverable and diff-able, the deep mapped tower is neither.
▸lesson.inset.warning
The width tax is measurable. On a real project, blanket-wrapping the whole API surface in a recursive DeepPartial/DeepReadonly over generated types is a classic way to add 3–10s of Check time (visible in tsc --extendedDiagnostics as a jump in Instantiation count) and to make hovering a deeply-mapped type in the editor lag for seconds, because IntelliSense must instantiate the entire mapped result to render the tooltip. The fix is usually not a cleverer type but narrowing the scope — map only the keys you actually transform, or let codegen emit the flat type once instead of recomputing it on every check.
Given type Clone<T> = { [K in keyof T]: T[K] }, what is Clone<readonly [1, 2]>?
Order how TypeScript evaluates MyPartial<{ a: number; readonly b: string }> where MyPartial<T> = { [K in keyof T]?: T[K] }:
- 1 Recognise the constraint is keyof T, so this is a homomorphic mapped type linked to the source
- 2 Compute keyof T as the key union 'a' | 'b'
- 3 For each key, copy the source modifiers (readonly on b) and emit value type T[K]
- 4 Apply the added ? modifier to every key, yielding { a?: number; readonly b?: string }
- 01What is a mapped type, what does T[K] mean inside it, and how do you write Stringify and Clone?
- 02What are the four mapping modifiers, and what do MyPartial/MyRequired/MyReadonly/MyMutable produce on { a?: number; readonly b: string }?
- 03What makes a mapped type homomorphic, what does homomorphism preserve, and why does Record produce a plain object?
Mapped types gave you the loop — iterate keyof T, transform each value, and toggle ?/readonly — and homomorphism is the quiet guarantee that the loop preserves the source’s modifiers and container shape. Next, key remapping via as breaks open the key side of that loop: instead of reusing each key unchanged, you compute a new key per iteration — renaming with get${Capitalize<K>}, or filtering keys out entirely by remapping them to never. Now when you see Partial<Config> on a team codebase, you’ll know exactly which line drives it — and why adding a field to the config never breaks the mapped type.
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.