Mapped Types
A loop over keys
Section titled “A loop over keys”A mapped type iterates over the keys of a type and produces a new type. The syntax mirrors an object type, but with [K in ...] where the keys would be — a type-level for loop.
type Flags = { [K in "a" | "b" | "c"]: boolean;};// ^? { a: boolean; b: boolean; c: boolean }K ranges over each member of the union "a" | "b" | "c", and for each one the type produces a property. Combine that with keyof and you can iterate over an existing type:
interface User { id: number; name: string; }
type Stringify = { [K in keyof User]: string;};// ^? { id: string; name: string }Referencing the original value type
Section titled “Referencing the original value type”Inside the loop, T[K] (indexed access) gives you the original value type — so you can transform it rather than replace it:
interface User { id: number; name: string; }
type Getters<T> = { [K in keyof T]: () => T[K];};
type UserGetters = Getters<User>;// ^? { id: () => number; name: () => string }Each property becomes a function returning that property original type. This “keep the keys, transform the values” shape is the everyday use of mapped types.
Mapping modifiers: add or remove readonly and ?
Section titled “Mapping modifiers: add or remove readonly and ?”Mapped types can also change the modifiers on each property. You add readonly or ? by writing them, and you remove them with a - prefix.
// Make every property optional (this is how Partial works)type MyPartial<T> = { [K in keyof T]?: T[K];};
// Make every property readonly (this is Readonly)type MyReadonly<T> = { readonly [K in keyof T]: T[K];};
// Strip optional AND readonly off every propertytype Concrete<T> = { -readonly [K in keyof T]-?: T[K];};flowchart LR
src["T: { id: number, name: string }"] -->|"for each K in keyof T"| loop["apply transform:
modifier + value type"]
loop --> out["new type:
{ id?: number, name?: string }"] The -? removes optionality and -readonly removes immutability — which is exactly how the built-in Required and a mutable-clone utility are written. When you see -? in a library, it means “force every property to be present.”
Key remapping with as
Section titled “Key remapping with as”A newer feature lets you rename or filter keys during the map using an as clause. This is how you build things like a “getters” type with get-prefixed names, or drop keys whose value type you do not want:
// Rename each key to get<Key>type Getters2<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];};
type UG = Getters2<{ name: string }>;// ^? { getName: () => string }
// Filter: keep only string-valued properties (remap unwanted keys to never)type StringProps<T> = { [K in keyof T as T[K] extends string ? K : never]: T[K];};Remapping a key to never removes it from the result — the standard trick for filtering properties by their value type.
Homomorphic mapped types: modifiers travel with the shape
Section titled “Homomorphic mapped types: modifiers travel with the shape”A mapped type is called homomorphic when it maps directly over [K in keyof T] for a single bare type parameter T — nothing wrapped around keyof T, nothing else mixed in. A homomorphic mapped type gets a feature no other mapped type does: it automatically copies the readonly and ? modifiers from the source type, on top of whatever modifier the mapped type itself adds.
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;type Expect<T extends true> = T;
interface Source { readonly a: string; b?: number;}
// Homomorphic: maps directly over keyof Ttype Copy<T> = { [K in keyof T]: T[K] };type Copied = Copy<Source>;
// `a` is still readonly -- this assignment is a real TS2540, proving itdeclare const copied: Copied;// @ts-expect-error -- readonly a is preserved from Sourcecopied.a = "changed";
// `b` is still optional -- omitting it compilesconst noB: Copied = { a: "x" };Compare that to a mapped type whose key comes from a plain, separate type parameter — not keyof T inside the same alias:
// Non-homomorphic: K is just a string-constrained parameter, not keyof T heretype FromKeys<K extends string, V> = { [P in K]: V };
interface Source { readonly a: string; b?: number;}
type NotCopied = FromKeys<keyof Source, Source[keyof Source]>;
declare const notCopied: NotCopied;notCopied.a = "changed"; // ✅ compiles -- readonly was NOT preserved// @expect-error TS2741interface Source { readonly a: string; b?: number;}
type FromKeys<K extends string, V> = { [P in K]: V };type NotCopied = FromKeys<keyof Source, Source[keyof Source]>;
const missingB: NotCopied = { a: "x" }; // ❌ b is now REQUIRED -- optional was NOT preservedThis is not folklore — it is exactly the difference between two utility types you already know. Partial<T> = { [K in keyof T]?: T[K] } is homomorphic, which is why Partial of a type with a readonly property keeps it readonly. Record<K, V> = { [P in K]: V } is not homomorphic — its K is a bare keyof any-constrained parameter with nothing to copy modifiers from, so Record can only ever produce plain mutable, required properties, never readonly or ?> ones, no matter what you pass in. Wrapping keyof T in anything — even something as innocent as Extract<keyof T, string> — also turns homomorphism off, because the mapped type is no longer directly [K in keyof T]:
// @expect-error TS2741interface Source { readonly a: string; b?: number;}
// keyof T wrapped in Extract -- no longer the bare `keyof T` shape, so homomorphism is lost tootype Wrapped<T> = { [K in Extract<keyof T, string>]: T[K] };type WrappedResult = Wrapped<Source>;
const test: WrappedResult = { a: "x" }; // ❌ b is required againA mapped type over a naked type parameter distributes over unions
Section titled “A mapped type over a naked type parameter distributes over unions”A second, separate piece of special behavior: when a homomorphic mapped type’s bare type parameter T is instantiated with a union, the mapped type runs once per member and unions the results — the same distributive instinct as a conditional type over a naked checked type, just applied to the mapping instead of a branch.
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;type Expect<T extends true> = T;
interface A { a: number }interface B { b: string }
type Boxed<T> = { [K in keyof T]: T[K][] };
type Result = Boxed<A | B>;// ^? { a: number[] } | { b: string[] }
type _distributes = Expect<Equal<Result, { a: number[] } | { b: string[] }>>;This might look surprising if you expect keyof (A | B) reasoning — keyof (A | B) alone is never (there is no key common to both, since A and B share no property names), which would make a non-distributing mapped type collapse to {}. But because Boxed<T>’s T is naked, TypeScript distributes the whole mapped type over the union before computing keyof on each piece — running Boxed<A> and Boxed<B> separately and unioning the two resulting object types, exactly like a distributive conditional’s per-member branches.
Arrays and tuples map to arrays and tuples
Section titled “Arrays and tuples map to arrays and tuples”A homomorphic mapped type applied to an array or tuple type does not turn it into a numeric-keyed object — it produces another array or tuple, preserving the exact shape (including tuple length and each element’s position):
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;type Expect<T extends true> = T;
type Boxed<T> = { [K in keyof T]: T[K][] };
type TupleResult = Boxed<[string, number]>;type _tupleCheck = Expect<Equal<TupleResult, [string[], number[]]>>;
type ArrResult = Boxed<string[]>;type _arrCheck = Expect<Equal<ArrResult, string[][]>>;Boxed<[string, number]> becomes [string[], number[]] — a two-element tuple, not { 0: string[]; 1: number[]; length: number[] }. This is the same homomorphic special-casing at work: TypeScript recognizes arrays and tuples as a shape worth preserving, not just an object with numeric-looking keys.
Homomorphic mapped types preserve modifiers and shape; wrapping keyof T loses both
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;type Expect<T extends true> = T;
interface Source { readonly a: string; b?: number;}
// homomorphic: readonly + optional both preservedtype Copy<T> = { [K in keyof T]: T[K] };declare const copied: Copy<Source>;const okOmit: Copy<Source> = { a: "x" }; // b is still optional
// non-homomorphic: readonly and optional are both LOSTtype FromKeys<K extends string, V> = { [P in K]: V };type NotCopied = FromKeys<keyof Source, Source[keyof Source]>;declare const notCopied: NotCopied;notCopied.a = "changed"; // allowed -- readonly is gone
// naked T distributes over a union before keyof is computed per-branchinterface A { a: number }interface B { b: string }type Boxed<T> = { [K in keyof T]: T[K][] };type Result = Boxed<A | B>;type _distributes = Expect<Equal<Result, { a: number[] } | { b: string[] }>>;
// arrays/tuples map to arrays/tuples, not numeric-keyed objectstype TupleResult = Boxed<[string, number]>;type _tupleCheck = Expect<Equal<TupleResult, [string[], number[]]>>; Mental Model
Section titled “Mental Model”flowchart TD
bare["[K in keyof T] -- T is a single bare type parameter"] --> homo["Homomorphic: copies readonly/optional from T, preserves array/tuple shape"]
wrapped["[K in SomeExpression<keyof T>] or [P in K] for an unrelated K"] --> nonhomo["Not homomorphic: builds a plain object from scratch -- no modifiers to copy"]
homo --> union{"T instantiated with a union?"}
union -->|yes| dist["Distributes: runs once per member, unions the results"] The question to ask about any mapped type is not “does it use keyof” but “is keyof T the entire thing being mapped over, with T a single bare parameter.” Partial, Required, Readonly, and Pick all qualify — which is why they preserve readonly/? and array/tuple shape, and why they distribute over unions passed to them. Record does not qualify, because its key parameter was never keyof of the value type to begin with.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Homomorphic form ({ [K in keyof T]: ... }) | Preserves readonly/? and array/tuple shape automatically, and distributes over unions for free | Only works when there is a single source type T to copy modifiers from — you cannot use it to build a type keyed by an arbitrary string union unrelated to any object |
Non-homomorphic form ({ [P in K]: V }) | Lets you build a type from any key set at all, including one with no matching object type — this is exactly Record | Always produces plain, mutable, required properties — there is no way to make it preserve modifiers, because there is no source object to copy them from |
| Relying on automatic union distribution | Free per-member behavior for object unions with disjoint key sets — no Exclude/conditional gymnastics needed | Easy to forget it is even happening; a mapped type that “should” produce one merged object type instead silently produces a union, one branch per input member |
Common Misconceptions
Section titled “Common Misconceptions”- Any mapped type that mentions
keyof Tis homomorphic and preserves modifiers. Only a mapped type whose entire key expression is the barekeyof Tof a single type parameterTqualifies. Wrapping it inExtract<keyof T, string>, or extractingkeyof Tinto a separate type parameter first, both turn homomorphism off — modifiers are lost either way. Record<K, V>should preservereadonly/?if you pass it akeyof Tthat came from a type with those modifiers. It cannot — by the timeRecordseesK, it is just a union of string/number/symbol literals, with no memory of which object type (if any) they came from, and no modifiers to copy.- A mapped type over an object union computes
keyofof the whole union first, so a union of disjoint-key objects collapses to{}. That is true only when the type parameter is not naked (e.g., already resolved to a specific union before reaching the mapped type). A bare nakedTdistributes the whole mapping over each union member first, then computeskeyofper member — which is why disjoint-key unions still produce a sensible per-member result instead of{}.
Reading AI-generated code
Section titled “Reading AI-generated code”- A hand-rolled “clone with extra readonly” or “deep partial” utility that looks correct on a plain object often breaks silently once someone passes it a type with a
readonlyor optional property — if the mapped type routes the key through anything other than a barekeyof T, check the readonly/optional case specifically, since the compiler will not warn you that homomorphism was lost. - A generic utility built as
{ [K in SomeKeys]: SomeValue }whereSomeKeyswas computed in a previous type alias (rather than beingkeyof Twritten directly in this one) cannot preserve source modifiers even if the author’s comment claims it does — that claim needs areadonly/optional assignability probe, not a read-through. - Code that expects a mapped type over a union of object types to produce one merged object (all keys present, “just union everything together”) is fighting how mapped types actually behave: a naked
Tdistributes per-member instead, which is usually what you want, but is worth calling out explicitly in a comment so the next reader does not “fix” it into a non-distributing form by wrappingTin brackets.
🐛 Spot the bug
interface Config {readonly apiUrl: string;timeout?: number;}
// "DeepPartial-lite": intended to keep readonly/optional exactly like Config, just re-typedtype ConfigKeys = keyof Config;type RebuildConfig<K extends ConfigKeys, V> = { [P in K]: V };
type Rebuilt = RebuildConfig<ConfigKeys, Config[ConfigKeys]>;
declare const rebuilt: Rebuilt;rebuilt.apiUrl = "https://changed.example"; // should this be allowed?Show the answer
This compiles with zero errors, including the reassignment on the last line — but that is the bug, not a clean pass. RebuildConfig extracted keyof Config into a separate type alias (ConfigKeys) before mapping over it, so by the time RebuildConfig’s own [P in K] runs, K is just a bare string-literal-union type parameter with no connection to Config at all. That breaks homomorphism completely: apiUrl’s readonly and timeout’s ? are both silently dropped, so Rebuilt ends up with every property mutable and required — the exact opposite of what a “keep it like Config” utility should do. The fix is to make the mapped type itself the thing that writes keyof T: type RebuildConfig<T> = { [K in keyof T]: T[K] } applied as RebuildConfig<Config>, which is homomorphic (the bare keyof T of a single parameter T) and correctly keeps apiUrl readonly and timeout optional — verify it with exactly the assignment probe used above, since the compiler gives no error either way and a “does it type-check” glance is not enough.