Skip to content
TR

Phase 5 · Type-level programming · Lesson 5.2

Advanced

Mapped types

Loop over keys at the type level to transform one object type into another: rebuild Partial, Readonly, Pick and Record, change modifiers, and rename or filter keys with `as`.

25 min

You have a User type. Now you need the same shape with every field optional for a PATCH body, a read-only version for a cache, a version where each field is a validation error message, and a set of getX() methods. Writing each by hand means four copies that drift apart. A mapped type is a loop over keys that builds a new object type from an old one, so every variant stays in sync with the original automatically.

The basic shape

A mapped type iterates over a union of keys and produces one property per key:

type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
 
type Flags = { [K in "darkMode" | "beta" | "compact"]: boolean };
type _t1 = Expect<Equal<Flags, { darkMode: boolean; beta: boolean; compact: boolean }>>;
 
type Echo = { [K in "a" | "b"]: K };
type _t2 = Expect<Equal<Echo, { a: "a"; b: "b" }>>;

Read [K in Keys]: Type as "for each K in the union Keys, add a property named K whose type is Type". K is a type variable you can use on the right-hand side, as Echo shows.

The keys usually come from another type with keyof, and the value usually comes from that type with indexed access:

type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
 
interface User {
  id: number;
  name: string;
  active: boolean;
}
 
type Stringified<T> = { [K in keyof T]: string };
type ErrorMessages<T> = { [K in keyof T]: string | null };
type Wrapped<T> = { [K in keyof T]: { value: T[K]; dirty: boolean } };
 
type _t1 = Expect<Equal<Stringified<User>, { id: string; name: string; active: string }>>;
type _t2 = Expect<Equal<Wrapped<User>["id"], { value: number; dirty: boolean }>>;

{ [K in keyof T]: T[K] } on its own is the identity: it copies T. Everything interesting comes from changing the value side (T[K]), the modifiers, or the keys.

Modifiers: readonly and ?, with + and -

You can add or remove the two property modifiers inside the brackets. A + adds (it is also the default when you write no sign), a - removes.

type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
 
type MyPartial<T> = { [K in keyof T]?: T[K] };
type MyRequired<T> = { [K in keyof T]-?: T[K] };
type MyReadonly<T> = { readonly [K in keyof T]: T[K] };
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
 
interface Draft {
  readonly id: number;
  title?: string;
}
 
type _t1 = Expect<Equal<MyPartial<Draft>, { readonly id?: number; title?: string }>>;
type _t2 = Expect<Equal<MyRequired<Draft>, { readonly id: number; title: string }>>;
type _t3 = Expect<Equal<Mutable<Draft>, { id: number; title?: string }>>;
type _t4 = Expect<Equal<MyReadonly<Draft>, { readonly id: number; readonly title?: string }>>;

These four are exactly how the built-in Partial, Required, Readonly are defined. Mutable is not built in, but it is the standard way to strip readonly.

Look at _t2 closely: -? does more than remove the question mark. It also removes the undefined that optionality added, so title becomes string, not string | undefined.

Homomorphic mapped types keep modifiers

Notice that MyPartial<Draft> kept readonly on id, and Mutable<Draft> kept ? on title. That is not an accident. A mapped type of the form { [K in keyof T]: ... }, where T is the type being mapped, is called homomorphic ("same shape"). Homomorphic mapped types copy the modifiers of the original properties, and you only change the ones you explicitly add or remove.

The rule depends on the form in keyof T. If you compute the keys somewhere else first, the link to T is lost:

type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
 
interface Account {
  readonly id: number;
  nickname?: string;
}
 
type Copy = { [K in keyof Account]: Account[K] };
type _t1 = Expect<Equal<Copy, { readonly id: number; nickname?: string }>>;
 
type AccountKey = keyof Account;
type LostModifiers = { [K in AccountKey]: Account[K] };
type _t2 = Expect<Equal<LostModifiers, { id: number; nickname: string | undefined }>>;

LostModifiers iterates the same keys, but through an alias, so TypeScript no longer knows they came from Account. readonly and ? are gone. nickname still carries undefined, because Account["nickname"] is string | undefined, but the property is now required.

There is one more homomorphic form: [P in K] where K is a type parameter constrained to keyof T. That is how Pick keeps modifiers.

Rebuilding Pick and Record

type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
 
type MyPick<T, K extends keyof T> = { [P in K]: T[P] };
type MyRecord<K extends keyof any, V> = { [P in K]: V };
 
interface User {
  readonly id: number;
  name: string;
  email?: string;
}
 
type Preview = MyPick<User, "id" | "email">;
type _t1 = Expect<Equal<Preview, { readonly id: number; email?: string }>>;
type _t2 = Expect<Equal<Preview, Pick<User, "id" | "email">>>;
 
type Scores = MyRecord<"alice" | "bob", number>;
type _t3 = Expect<Equal<Scores, { alice: number; bob: number }>>;
 
type Lookup = MyRecord<string, boolean>;
type _t4 = Expect<Equal<Lookup, { [key: string]: boolean }>>;
 
// @ts-expect-error -- Type '"age"' does not satisfy the constraint 'keyof User'.
type Bad = MyPick<User, "age">;
  • Pick constrains K extends keyof T, so you can't pick a key that does not exist, and modifiers are preserved.
  • Record has no source object: it is not homomorphic, every property is plain and required. keyof any (that is, string | number | symbol) is the constraint for "anything that can be a key".
  • Mapping over string (a non-literal key type) produces an index signature, as Lookup shows.

Quick check

What is Partial<Pick<Product, "price">>?

interface Product {
  readonly price: number;
  name: string;
}

Key remapping with as

Since TypeScript 4.1 you can transform each key with an as clause: [K in keyof T as NewKey]. The new key can be any type expression that uses K.

Renaming keys

type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
 
type Getters<T> = {
  [K in keyof T as `get${Capitalize<K & string>}`]: () => T[K];
};
 
interface Person {
  name: string;
  age: number;
}
 
type PersonGetters = Getters<Person>;
type _t1 = Expect<Equal<PersonGetters, { getName: () => string; getAge: () => number }>>;
 
const getters: PersonGetters = {
  getName: () => "Ada",
  getAge: () => 36,
};
console.log(getters.getName(), getters.getAge());

▶ Try it in the TypeScript Playground

K & string is needed because keyof T can contain number and symbol keys, and Capitalize only accepts strings. Intersecting with string drops the non-string keys. Template literal types get their own lesson; for now, read it as string concatenation at the type level.

Filtering keys with never

If the as clause produces never, the property is dropped. That turns a mapped type into a filter:

type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
 
type DataOnly<T> = {
  [K in keyof T as T[K] extends (...args: any[]) => any ? never : K]: T[K];
};
 
type OmitKind<T> = { [K in keyof T as Exclude<K, "kind">]: T[K] };
 
interface Model {
  kind: "model";
  id: number;
  label?: string;
  save(): void;
}
 
type _t1 = Expect<Equal<DataOnly<Model>, { kind: "model"; id: number; label?: string }>>;
type _t2 = Expect<Equal<OmitKind<Model>, { id: number; label?: string; save(): void }>>;

Two useful facts here:

  • An as clause over keyof T still preserves modifiers: label stays optional. That makes as + Exclude a modifier-preserving alternative to Omit.
  • The condition can inspect the value (T[K] extends ...) to decide the key. "Keys whose values are functions" is a classic interview exercise.

The built-in Omit<T, K> is defined differently (Pick<T, Exclude<keyof T, K>>), and its K is only constrained to keyof any, so Omit<User, "typo"> is not an error. People are often surprised by that.

Mapping to a union of entries

Sometimes you want a union, not an object. Build an object whose values are what you want, then index it with [keyof T] to collect them:

type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
 
type Entry<T> = { [K in keyof T]: [key: K, value: T[K]] }[keyof T];
 
interface Settings {
  theme: "light" | "dark";
  fontSize: number;
}
 
type SettingEntry = Entry<Settings>;
type _t1 = Expect<Equal<SettingEntry, [key: "theme", value: "light" | "dark"] | [key: "fontSize", value: number]>>;
 
function update(...[key, value]: SettingEntry) {
  console.log(key, value);
}
 
update("fontSize", 16);
// @ts-expect-error -- "blue" is not a valid value for the "theme" key.
update("theme", "blue");

This "map then index" pattern keeps each key paired with its own value type, which [keyof T, T[keyof T]] would not (that type would allow ["theme", 16]).

Quick check

What is Setters<Point>?

type Setters<T> = {
  [K in keyof T as `set${Capitalize<K & string>}`]: (v: T[K]) => void;
};
type Point = { x: number; y: number };

When mapped types distribute: unions, arrays and tuples

A homomorphic mapped type applied to a generic type argument has three special behaviors. They explain results that otherwise look like bugs.

type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
 
type Box<T> = { [K in keyof T]: { value: T[K] } };
 
// 1. Unions: the mapped type is applied to each member separately.
type U = Box<{ a: string } | { b: number }>;
type _t1 = Expect<Equal<U, Box<{ a: string }> | Box<{ b: number }>>>;
 
// 2. Arrays and tuples: the result is still an array or tuple.
type T2 = Box<[string, number]>;
type _t2 = Expect<Equal<T2, [{ value: string }, { value: number }]>>;
type A2 = Box<boolean[]>;
type _t3 = Expect<Equal<A2, { value: boolean }[]>>;
 
type _t4 = Expect<Equal<Readonly<string[]>, readonly string[]>>;
type _t5 = Expect<Equal<Partial<[string, number]>, [string?, number?]>>;
 
// 3. Primitives pass straight through.
type _t6 = Expect<Equal<Box<string>, string>>;
type _t7 = Expect<Equal<Partial<number>, number>>;
  1. Unions distribute. Box<A | B> becomes Box<A> | Box<B>, not a box of the merged keys (remember keyof (A | B) is only the common keys, which would lose data).
  2. Arrays and tuples stay arrays and tuples. Only the element positions are mapped; length, push and friends are left alone. That is why Readonly<string[]> is readonly string[] and Partial of a tuple makes its elements optional.
  3. Primitives are returned unchanged. Partial<number> is number.

All three only apply when the mapped type is generic over T. Write the same thing directly against a concrete array type and you map every array member instead:

type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
 
type Arr = string[];
type Direct = { [K in keyof Arr]: 1 };
 
type _t1 = Expect<Equal<Direct["push"], 1>>;
type _t2 = Expect<Equal<Direct["length"], 1>>;

push became 1. That is almost never what you want, which is why these utilities are always written as generics.

Spot the error

This Nullable helper should make every field accept null, while keeping readonly and optional fields as they were. It compiles, but the result is wrong. What is wrong?

interface Profile {
  readonly id: number;
  bio?: string;
}
 
type ProfileKeys = keyof Profile;
type Nullable = { [K in ProfileKeys]: Profile[K] | null };
 
const p: Nullable = { id: 1, bio: undefined };
p.id = 2; // no error: readonly was lost
Show the answer

The keys come from an alias, ProfileKeys, so the mapped type is not homomorphic and drops the modifiers: id is no longer readonly, and bio became a required property of type string | undefined | null (you must now write bio: undefined). Map over keyof T directly, as a generic:

type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
 
interface Profile {
  readonly id: number;
  bio?: string;
}
 
type Nullable<T> = { [K in keyof T]: T[K] | null };
 
type _t1 = Expect<Equal<Nullable<Profile>, { readonly id: number | null; bio?: string | null }>>;
 
const p: Nullable<Profile> = { id: 1 };
// @ts-expect-error -- Cannot assign to 'id' because it is a read-only property.
p.id = 2;

Now bio can be omitted and id is protected again.

Recap

  • { [K in Keys]: V } builds one property per member of the Keys union; K is usable in V.
  • ? and readonly can be added (+, the default) or removed (-). -? also strips undefined.
  • Homomorphic mapped types ([K in keyof T], or [P in K] with K extends keyof T) keep the original modifiers. Going through a key alias loses them.
  • Partial, Required, Readonly, Pick and Record are one-line mapped types; Record is not homomorphic.
  • as remaps keys: rename with template literals, drop keys by producing never. It still preserves modifiers.
  • "Map then index with [keyof T]" turns an object type into a union of per-key types.
  • Generic homomorphic mapped types distribute over unions, map arrays and tuples to arrays and tuples, and leave primitives unchanged.

Interview cards

1 / 7