EduFlow

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.

  1. 1The loop
  2. 2Adding and removing modifiers
  3. 3Renaming keys with as
  4. 4Putting it to work

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:

TypeScript
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

TypeScript
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.

The keys of Address, line1, city, country and postcode, each pass through the mapped type body, which wraps the property type in an object with value, error and touched, producing one property per key in FieldState of Address. keyof Address 'line1' 'city' 'country' 'postcode' [K in keyof T]: { value: T[K]; error; touched } FieldState line1: {…} city: {…} country: {…} postcode?: {…}
A mapped type runs its body once per key

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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
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 T is homomorphic, so optional and readonly modifiers are copied from T unless you add or remove them.
  • +?, -?, +readonly and -readonly add or strip modifiers; Required<T> is just { [K in keyof T]-?: T[K] }.
  • An as clause renames each key, and mapping a key to never removes it, which is how Omit works.
  • Built-in mapped types are shallow; nested objects keep their original modifiers.

Further reading

Check your understanding

4 questions

Question 1 of 4

What is Draft?

TypeScript
type Address = { readonly line1: string; city: string; postcode?: string };
type Draft = { -readonly [K in keyof Address]-?: Address[K] };
Question 2 of 4

Which definition re-implements Omit<T, K>?

Question 3 of 4

type Setters<T> = { [K in keyof T as set${Capitalize}]: (v: T[K]) => void } fails to compile. Why?

Question 4 of 4

Readonly<Order> is applied to type Order = { id: number; lines: { sku: string }[] }. Which assignment is still allowed?

Practice

Code playground

Re-implement the utility types

Using mapped types only (no built-in Partial, Required, Pick, Omit or Record), implement:

  • MyPartial<T>, MyRequired<T>, MyPick<T, K> and MyOmit<T, K>, behaving like the built-ins.
  • FieldState<T>: for every property, { value: T[K]; error: string | null; touched: boolean }.
  • Setters<T>: for every property name, a property setName that is a function taking that property’s type and returning void.

Then make initialState compile with its new return type.

type Address = { line1: string; city: string; country: string; postcode?: string };

// Re-implement these with mapped types (do not use the built-in utility types).
type MyPartial<T> = T;
type MyRequired<T> = T;
type MyPick<T, K extends keyof T> = T;
type MyOmit<T, K extends PropertyKey> = T;

// For each property: its value, a validation error and whether it was touched.
type FieldState<T> = any;

// For each property `name`, a `setName(value)` function.
type Setters<T> = any;

function initialState<T extends object>(values: T): FieldState<T> {
  return Object.fromEntries(
    Object.entries(values).map(([key, value]) => [key, { value, error: null, touched: false }]),
  );
}

// ---- Type tests (do not edit) ----
type T1 = Expect<Equal<MyPartial<Address>, { line1?: string; city?: string; country?: string; postcode?: string }>>;
type T2 = Expect<Equal<MyRequired<Address>, { line1: string; city: string; country: string; postcode: string }>>;
type T3 = Expect<Equal<MyPick<Address, 'city' | 'postcode'>, { city: string; postcode?: string }>>;
type T4 = Expect<Equal<MyOmit<Address, 'postcode' | 'country'>, { line1: string; city: string }>>;
type T5 = Expect<Equal<FieldState<{ city: string; floor: number }>, {
  city: { value: string; error: string | null; touched: boolean };
  floor: { value: number; error: string | null; touched: boolean };
}>>;
type T6 = Expect<Equal<Setters<{ city: string; floor: number }>, {
  setCity: (value: string) => void;
  setFloor: (value: number) => void;
}>>;

const form = initialState({ line1: '12 Nile St', city: 'Cairo', country: 'EG' });
console.log(form.city);
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • The file type-checks with zero errors, including all type tests
  • initialState builds an untouched, error-free state for each field