Modern Web · Typescript

TypeScript Mapped Types — A Field Guide

A practical guide to TypeScript mapped types: the { [K in keyof T]: ... } syntax, key remapping with as, the +readonly/-readonly and +?/-? modifiers, and building your own Getters, Partial, and Readonly from scratch.

John Kihiu12 min read

Mapped types are how TypeScript lets you build a new object type by transforming every property of an existing one, instead of writing the same shape out by hand three different ways. Once the syntax clicks, you stop writing one-off interfaces for every variant of a type and start writing a single transformation that produces all of them. This is the mechanism behind Partial, Readonly, Pick, and Record in the standard lib — and once you can write those yourself, most of what looks like "advanced TypeScript" in library type definitions stops being mysterious.

The basic syntax

A mapped type has one job: iterate over the keys of a type and produce a value for each one. The shape is { [K in keyof T]: SomeTransformOf }. keyof T gives you the union of T's property names, K in iterates over that union the way a for...in loop would at runtime, and whatever you write after the colon decides what each resulting property looks like.

TypeScript
interface Product {
  id: number;
  name: string;
  price: number;
}

// Reimplementing Partial<T> from scratch
type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

// Reimplementing Readonly<T> from scratch
type MyReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

type DraftProduct = MyPartial<Product>;
// { id?: number; name?: string; price?: number }

type FrozenProduct = MyReadonly<Product>;
// { readonly id: number; readonly name: string; readonly price: number }

That's genuinely all Partial and Readonly are in lib.es5.d.ts — a mapped type with a modifier tacked onto the property. There's no special compiler magic beyond what mapped types already give you.

Modifiers: adding and removing readonly and optional

The ? and readonly modifiers can be added or stripped explicitly with + and - prefixes. Writing readonly alone (as above) implies +readonly; writing -readonly removes it even if the source type already has it. The same applies to +?/-? for optionality. This is what lets you write a type that goes the opposite direction from Partial and Readonly: one that unlocks a type instead of locking it down.

TypeScript
interface Config {
  readonly apiUrl?: string;
  readonly retries?: number;
}

// Strip both readonly and optional — the inverse of Partial<Readonly<T>>
type Concrete<T> = {
  -readonly [K in keyof T]-?: T[K];
};

type ResolvedConfig = Concrete<Config>;
// { apiUrl: string; retries: number }

This matters in practice more than it looks: config objects are frequently defined as fully optional and readonly for the caller, but once you've merged them with defaults internally, every field really is required and mutable. Concrete<T> expresses that without redeclaring the interface.

Modifiers only add or remove — they don't transform

+readonly/-readonly and +?/-? control the property's modifiers, not its value type. To change the actual type of each property (e.g. wrap it in a function, or convert it to a string), you do that in the value position after the colon, which is a separate step from the modifier syntax.

Key remapping with `as`

TypeScript 4.1 added the ability to rename keys during the mapping, using an as clause right after the in clause: { [K in keyof T as NewKeyType]: T[K] }. NewKeyType is usually a template literal type built from K, and this is the piece that makes it possible to generate a whole family of derived method names — getters, setters, event handler names — from a single object shape.

TypeScript
interface Person {
  name: string;
  age: number;
  email: string;
}

// Turn every property into a getXxx() method
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type PersonGetters = Getters<Person>;
// {
//   getName: () => string;
//   getAge: () => number;
//   getEmail: () => string;
// }

function buildGetters<T extends object>(obj: T): Getters<T> {
  const result = {} as Getters<T>;
  for (const key of Object.keys(obj) as (keyof T)[]) {
    const getterName = `get${String(key).charAt(0).toUpperCase()}${String(key).slice(1)}`;
    (result as any)[getterName] = () => obj[key];
  }
  return result;
}

The string & K cast in the template literal is there because keyof T can in principle include number or symbol keys, and template literal types only accept string, number, bigint, boolean, null, or undefined as interpolated types — intersecting with string narrows K down to the string keys only, at the type level. The runtime implementation in buildGetters is unrelated code that has to actually produce the object the type describes; TypeScript won't generate it for you.

as can also filter keys out entirely, by mapping a key to never. A property that maps to never is dropped from the resulting type, which is the mechanism Omit<T, K> is built on internally.

TypeScript
// Drop keys whose value type is a function — useful for stripping
// methods off a class instance type to get a plain data shape
type DataOnly<T> = {
  [K in keyof T as T[K] extends Function ? never : K]: T[K];
};
Remapped keys still have to be valid property keys

The type produced by the as clause must be assignable to string | number | symbol. If you build a template literal from a union that includes something incompatible, or forget the string & narrowing on a generic keyof T, you'll get a compiler error pointing at the as clause rather than at the property itself — worth knowing so you don't go hunting in the wrong place.

Homomorphic mapped types and why modifiers carry over

A mapped type of the exact shape { [K in keyof T]: ... }, with no key remapping, is called homomorphic — it copies over whatever modifiers the source properties already had (readonly, ?) by default, on top of anything you add explicitly. That's why Partial<T> preserves a source property's existing readonly instead of stripping it, even though Partial only mentions ? in its definition. As soon as you add an as clause to remap keys, the mapped type stops being homomorphic, and modifiers are no longer carried over automatically — each property starts from a clean slate and you have to add back whatever modifiers you actually want.

Wrapping up

Mapped types boil down to one loop — [K in keyof T] — plus three knobs on top of it: modifiers to add or strip readonly/?, an as clause to rename or drop keys, and whatever transformation you write in the value position. Partial, Readonly, Pick, Record, and Omit are all thin, specific instances of that same loop, which is why writing your own — a Getters<T>, a Concrete<T>, a DataOnly<T> — is rarely more than a few lines once you know where each piece goes. The syntax looks dense the first time you see it; it stops looking dense the first time you write one from scratch instead of copying it from a library.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.