TypeScript Mapped Types

A mapped type builds a new object type by iterating over the keys of an existing type and transforming each property. Instead of writing interface Foo { a: string; b: string; } by hand for every variation you need, you write the transformation once and let the compiler apply it to any type. This is how TypeScript’s own built-in utility types like Partial<T>, Readonly<T>, Pick<T>, and Record<K, V> are implemented — they are ordinary mapped types living in TypeScript’s standard library, not compiler magic.

Overview: How Mapped Types Work

Every object type is, structurally, a set of keys mapped to value types. A mapped type expresses this directly: { [K in keyof T]: SomeTransformOf<T, K> }. The keyof T operator produces a union of all property names of T as a type (a union of string/number/symbol literals). The in keyword, borrowed syntactically from for...in, tells the compiler to iterate that union and produce one property for each member. For every key K in that union, T[K] (an indexed access type) looks up the type of that specific property on T.

Because the iteration is driven by keyof T, mapped types are generic by nature — you almost always write them as type X<T> = { [K in keyof T]: ... } so they can be reused across many source types. When TypeScript’s mapped type is defined using exactly keyof T as its key source (not a fresh union), it is called a homomorphic mapped type: the compiler preserves the underlying modifiers (optional ?, readonly) and even the tuple/array-ness of T unless you explicitly override them. This is why Partial<T> works correctly on arrays and tuples, not just plain objects.

Mapped types are a purely compile-time construct. Nothing about [K in keyof T] exists in the emitted JavaScript — like all TypeScript types, they are erased entirely during compilation. At runtime you just have plain objects; the mapped type only shaped how the compiler checked your code before that point.

Syntax

type MappedType<T> = {
  readonly [K in keyof T]?: T[K];
};
Part Meaning
K in keyof T Iterates every property key of T, binding each one to the local type variable K.
T[K] Indexed access — the type of property K on the original type T. You can also transform it, e.g. T[K] | null.
readonly prefix Adds a readonly modifier to every generated property.
-readonly prefix Strips an existing readonly modifier from the source type.
? suffix Makes every generated property optional.
-? suffix Strips optionality — forces every property to be required.
as clause (TS 4.1+) Remaps the key name itself, e.g. [K in keyof T as `get${string & K}`].

Examples

Example 1: A basic mapped type — making every property readonly

interface Product {
  name: string;
  price: number;
  inStock: boolean;
}

type ReadonlyVersion<T> = {
  readonly [K in keyof T]: T[K];
};

const product: ReadonlyVersion<Product> = {
  name: "Widget",
  price: 9.99,
  inStock: true,
};

console.log(product.name);
// product.price = 12.99; // compile error: read-only property

Output:

Widget

ReadonlyVersion<T> re-declares every property of T with a readonly modifier added. Because it’s homomorphic (the source of the iteration is literally keyof T applied to the type parameter), TypeScript keeps every original property’s type exactly as it was — it only layers readonly on top. This is essentially how the built-in Readonly<T> utility type works.

Example 2: Deriving a “flags” type and building a value for it

interface FormState {
  username: string;
  email: string;
  age: number;
}

type OptionalFlags<T> = {
  [K in keyof T]?: boolean;
};

function createTouchedState<T extends object>(obj: T): OptionalFlags<T> {
  const result: Record<string, boolean> = {};
  for (const key in obj) {
    result[key] = false;
  }
  return result as OptionalFlags<T>;
}

const touched = createTouchedState<FormState>({ username: "", email: "", age: 0 });
console.log(touched);

Output:

{ username: false, email: false, age: false }

OptionalFlags<T> turns every property of T into an optional boolean, regardless of what the original type was — this is a common pattern for tracking UI state like “has this field been touched?” for every field of a form. Note the implementation: iterating a generic T with for...in only gives you string keys, which the compiler can’t statically match back up against a generic mapped type’s keys. Building into a plain Record<string, boolean> and casting once at the return is the standard, pragmatic way to construct a value of a generic mapped type.

Example 3: Key remapping with as — generating getter methods

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

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

function createGetters<T extends object>(obj: T): Getters<T> {
  const result: Record<string, () => unknown> = {};
  for (const key in obj) {
    const capitalized = key.charAt(0).toUpperCase() + key.slice(1);
    result[`get${capitalized}`] = () => obj[key];
  }
  return result as unknown as Getters<T>;
}

const person: Person = { name: "Ada", age: 30 };
const personGetters = createGetters(person);
console.log(personGetters.getName());
console.log(personGetters.getAge());

Output:

Ada
30

This is where mapped types get powerful. The as clause after K lets you compute a brand-new key name instead of reusing the original one. Here, `get${Capitalize<string & K>}` is a template literal type that turns a key like "name" into the literal type "getName". string & K narrows K (which could theoretically include number or symbol) down to just its string-compatible part so Capitalize, which only accepts strings, is happy. The result is a type where getName and getAge exist as real, individually-typed keys — getName: () => string and getAge: () => number — not a generic [key: string]: () => unknown index signature.

Under the Hood: What the Compiler Actually Does

When TypeScript resolves MyMapped<SomeType>, it performs roughly these steps:

  • Evaluate keyof SomeType to get the union of property-name literal types (e.g. "name" | "age").
  • For each member of that union, substitute it for K and evaluate the property’s type expression (typically an indexed access like T[K], possibly transformed).
  • If an as clause is present, evaluate the new key expression for each K; if it evaluates to never for a given key, that property is dropped entirely — this is how you filter out keys inside a mapped type.
  • Apply any readonly/-readonly and ?/-? modifiers to each generated property.
  • Assemble all the generated properties into a single new object type.

All of this happens purely during type-checking. Once the compiler is satisfied that your code is sound, tsc strips every type annotation, interface, and mapped type declaration — the emitted JavaScript contains only the runtime logic (the for loops, function bodies, object literals). If you compiled any of the examples above and ran the output with Node, you would see ordinary JavaScript objects and functions with zero trace of keyof, Capitalize, or as clauses.

Common Mistakes

Mistake 1: Forgetting keyof

A mapped type must iterate over a union of property-name-like values (string | number | symbol), not over an arbitrary type parameter directly:

type BadMapped<T> = {
  [K in T]: boolean;
};
// Error: Type 'T' is not assignable to type 'string | number | symbol'.

The fix is to map over keyof T (the property names of T), not T itself:

type GoodMapped<T> = {
  [K in keyof T]: boolean;
};

Mistake 2: Assuming a mapped type is mutable by default

Copying a type with [K in keyof T]: T[K] preserves any readonly the source already had (homomorphic mapped types keep existing modifiers). Adding a plain readonly prefix only adds it — it can never remove one that’s already there, and forgetting this leads to confusing “cannot assign to read-only property” errors on a type you thought was just a copy:

interface Config {
  readonly host: string;
  readonly port: number;
}

type StillReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

const settings: StillReadonly<Config> = { host: "localhost", port: 8080 };
settings.host = "example.com";
// Error: Cannot assign to 'host' because it is a read-only property.

To actually strip readonly, use the -readonly modifier explicitly:

interface Config {
  readonly host: string;
  readonly port: number;
}

type Mutable<T> = {
  -readonly [K in keyof T]: T[K];
};

const settings: Mutable<Config> = { host: "localhost", port: 8080 };
settings.host = "example.com";
console.log(settings.host);

Output:

example.com

Best Practices

  • Reach for the built-in utility types (Partial, Required, Readonly, Pick, Record) first — write a custom mapped type only when none of them fit.
  • Keep mapped types homomorphic (iterate keyof T directly) whenever you want them to preserve the source type’s existing modifiers and work correctly on arrays/tuples.
  • When remapping keys with as, narrow the key with string & K before passing it to string-only utilities like Capitalize, Uppercase, or Lowercase, since K may technically include number or symbol.
  • Use as never in the remapping clause to filter out unwanted keys (e.g. [K in keyof T as T[K] extends Function ? never : K] keeps only non-function properties).
  • Avoid building values of a generic mapped type property-by-property with direct indexed assignment; it’s usually simpler and just as safe to build into a plain Record and cast once at the end.
  • Remember mapped types vanish at compile time — they only affect what tsc lets you write, not what your code does at runtime.

Practice Exercises

  • Write a mapped type Nullable<T> that turns every property of T into T[K] | null, then use it on an interface of your choice.
  • Write a mapped type Stringify<T> that converts every property’s type to string, regardless of its original type. (Hint: you don’t need T[K] at all in the property’s value type.)
  • Using key remapping, write EventHandlers<T> that turns an interface like { click: MouseEvent; scroll: Event } into { onClick: (e: MouseEvent) => void; onScroll: (e: Event) => void }.

Summary

  • A mapped type generates an object type by iterating keyof T with the [K in keyof T] syntax and transforming each property.
  • T[K] looks up the original type of each property; you can wrap or replace it entirely.
  • readonly/-readonly and ?/-? modifiers add or strip those qualities on every generated property.
  • The as clause (TS 4.1+) remaps key names, often combined with template literal types like `get${Capitalize<K>}`; mapping a key to never drops it.
  • Homomorphic mapped types (iterating keyof T directly) preserve the source’s existing modifiers and array/tuple shape.
  • Mapped types are erased at runtime — they exist purely to guide the compiler’s checking of your code.