In simple language, a mapped type is a for loop over the keys of a type. We take an existing type, iterate over its keys, and produce a new type where each property has been transformed somehow.
The syntax looks weird at first: { [K in keyof T]: ... }. Read it as “for each key K in the keys of T, give me back a property with this new shape”. It’s the type-level equivalent of Object.fromEntries(Object.keys(obj).map(...)).
The basic shape
type User = { id: number; name: string; email: string };
// Make every property optional — this is basically how Partial<T> is built
type PartialUser = { [K in keyof User]?: User[K] };
// { id?: number; name?: string; email?: string }
Notice three things: K in keyof User walks the keys, ? adds optionality, and User[K] is indexed access to grab the original property type. Swap those modifiers around and we get a whole family of transforms.
name: string }
readonly name: string }
Mapping modifiers — ? and readonly
We can add or remove modifiers with + (default) or -. The - is the cool one because it strips modifiers that already exist.
type MakeOptional<T> = { [K in keyof T]?: T[K] };
type MakeRequired<T> = { [K in keyof T]-?: T[K] }; // strip optional
type Freeze<T> = { readonly [K in keyof T]: T[K] };
type Thaw<T> = { -readonly [K in keyof T]: T[K] }; // strip readonly
type LooseConfig = { host?: string; port?: number };
type StrictConfig = MakeRequired<LooseConfig>; // { host: string; port: number }
This is exactly how Partial, Required, Readonly are implemented in the standard lib — it’s just a thin wrapper around a mapped type.
Key remapping with as
TS 4.1+ lets us rename keys during mapping using as. Combine it with template literal types and we can build getter shapes, event handlers, anything name-based.
type User = { name: string; age: number };
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<User>;
// { getName: () => string; getAge: () => number }
The as clause runs per-key and decides the new key name. If we return never from the as, that key gets filtered out — which is how we exclude properties.
type RemoveKey<T, K extends keyof T> = {
[P in keyof T as P extends K ? never : P]: T[P];
};
type WithoutEmail = RemoveKey<{ id: number; email: string }, "email">;
// { id: number }
Mapping a union of strings
K in doesn’t have to start from keyof — any union of keys works. Useful for building records out of thin air.
type Role = "admin" | "editor" | "viewer";
type RolePermissions = { [R in Role]: string[] };
// { admin: string[]; editor: string[]; viewer: string[] }
const perms: RolePermissions = {
admin: ["read", "write", "delete"],
editor: ["read", "write"],
viewer: ["read"],
};
This is basically Record<Role, string[]> written long-hand — and yes, Record itself is just a mapped type.
Real-world: building a form state type
Here’s the kind of pattern that shows up in actual code. Given a form’s value shape, derive matching error and touched maps automatically.
type FormValues = { email: string; password: string; remember: boolean };
type FormErrors<T> = { [K in keyof T]?: string };
type FormTouched<T> = { [K in keyof T]: boolean };
type LoginForm = {
values: FormValues;
errors: FormErrors<FormValues>; // { email?: string; ... }
touched: FormTouched<FormValues>; // { email: boolean; ... }
};
If we add a new field to FormValues, the errors and touched types update for free. That’s the whole point of mapped types — write the shape once, derive everything else.