Section 3 · Type-Level Programming
Mapped Types and Key Remapping
19 min3 min readCode playground
Lesson at a glance
Transform every property of a type at once with mapped types, add or remove readonly and optional modifiers, and rename or drop keys with as clauses, re-implementing Partial, Pick and Omit along the way.
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Cartwheel’s checkout has an address form. The address type is simple:
type Address = { line1: string; city: string; country: string; postcode?: string };The form needs more than the address, though. It needs, for each field, its current value, a validation error and whether the user has touched it. The PATCH endpoint needs a version where every field is optional. The review screen needs a frozen copy. You could write three more types by hand, each repeating the four field names, and update all of them whenever Address changes. Or you could write each one once, as a transformation. That is what a mapped type is: a loop over keys, at the type level.
The loop
type FieldState<T> = {
[K in keyof T]: { value: T[K]; error: string | null; touched: boolean };
};
type AddressForm = FieldState<Address>;
// {
// line1: { value: string; error: string | null; touched: boolean };
// city: { value: string; … };
// country: { value: string; … };
// postcode?: { value: string | undefined; … };
// }Read [K in keyof T] as “for each key K of T”. The right-hand side is evaluated once per key, with K bound to that key, so T[K] is that property’s type. Add phone to Address and AddressForm grows a phone entry, with no edit.
Notice that postcode stayed optional. When a mapped type loops over keyof T directly, TypeScript treats it as homomorphic: it copies each property’s ? and readonly modifiers from T. That is usually what you want, and it is why the built-in utility types preserve optionality.
You do not have to loop over keyof. Any union of keys works, which is all Record is:
type MyRecord<K extends PropertyKey, V> = { [P in K]: V };
type CountsByStatus = MyRecord<'processing' | 'shipped' | 'delivered', number>;Adding and removing modifiers
Put ? or readonly in front of the property to add it, and prefix with - to remove it. With those, most of the lib’s utility types are one line each:
type MyPartial<T> = { [K in keyof T]?: T[K] };
type MyReadonly<T> = { readonly [K in keyof T]: T[K] };
type MyRequired<T> = { [K in keyof T]-?: T[K] };
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type MyPick<T, K extends keyof T> = { [P in K]: T[P] };MyPartial<Address> is the PATCH body type. MyReadonly<Address> is the frozen copy for the review screen. -? does one more thing than you might expect: it also removes the undefined that an optional property implicitly carries, so MyRequired<Address>['postcode'] is string, not string | undefined.
MyPick is worth a second look. It maps over K, not keyof T, but because K is constrained to keyof T, TypeScript still treats it as homomorphic and keeps the modifiers of the picked properties.
Renaming keys with as
Since TypeScript 4.1, a mapped type can rename each key with an as clause. The new name can be any type expression involving K, typically a template literal:
type Setters<T> = {
[K in keyof T as `set${Capitalize<K & string>}`]: (value: T[K]) => void;
};
type AddressSetters = Setters<Address>;
// { setLine1: (value: string) => void; setCity: …; setCountry: …; setPostcode?: … }K & string is needed because keyof T may include number and symbol keys, and Capitalize only works on strings. You will see template literal types properly in Template Literal Types.
The as clause has a second power: a key mapped to never is dropped. That is exactly how Omit is defined:
type MyOmit<T, K extends PropertyKey> = { [P in keyof T as Exclude<P, K>]: T[P] };
type NewAddress = MyOmit<Address, 'postcode'>; // { line1; city; country }Exclude<P, K> gives never when P is one of the omitted keys and P otherwise. In the next lesson you will see how Exclude works, and how to filter keys by the type of their value rather than by name.
Putting it to work
Types like FieldState pay off in the functions that use them. Here is the form’s update function, written once for any form, not just addresses:
function setField<T, K extends keyof T>(form: FieldState<T>, key: K, value: T[K]): FieldState<T> {
return { ...form, [key]: { ...form[key], value, touched: true } };
}
setField(addressForm, 'city', 'Dubai'); // ok
setField(addressForm, 'city', 42); // Error: Argument of type 'number' is not assignable to parameter of type 'string'.
setField(addressForm, 'cty', 'Dubai'); // Error: Argument of type '"cty"' is not assignable to parameter of type 'keyof Address'.This is the pluck pattern from section 2 combined with a mapped type. K is inferred from the key you pass, T[K] checks the value against that field’s type, and the mapped type guarantees the form has an entry for every field. When a deliveryNotes field is added to Address, the form type, the setters and this function all accept it immediately, and every place that builds a complete form is flagged until it handles the new field.
In the exercise you will re-implement four utility types and build the address form’s types from them. Next lesson adds the missing piece, conditionals, so your transformations can make decisions.
Key takeaways
- A mapped type
{ [K in Keys]: … }loops over a union of keys and builds one property per key. - Mapping over
keyof Tis homomorphic, so optional and readonly modifiers are copied fromTunless you add or remove them. +?,-?,+readonlyand-readonlyadd or strip modifiers;Required<T>is just{ [K in keyof T]-?: T[K] }.- An
asclause renames each key, and mapping a key toneverremoves it, which is howOmitworks. - Built-in mapped types are shallow; nested objects keep their original modifiers.