the iteration of the type world
a mapped type is a for loop for types. you take a union
of keys, walk each one, and produce a new type whose properties are
derived from those keys. it sounds abstract — and then you realize
every utility type in the standard library is built this way.
Partial, Pick, Required,
Record, Readonly — all mapped types,
most of them one line.
// from the standard library — three tokens of mapped type.
type Partial<T> = { [K in keyof T]?: T[K] };
interface User { id: string; name: string; age: number }
type UserPatch = Partial<User>;
// ^? { id?: string; name?: string; age?: number }
read it left to right: for every key K in
keyof T, produce an optional property of the same
value type. one ? after the bracket. that's the whole
feature. ten characters of syntax, applied uniformly, gives you a
type that mirrors the original with every field optional.
the four modifiers
a mapped type can add or remove two modifiers: optionality
(?) and immutability (readonly). a leading
- removes the modifier instead of adding it. four
knobs, infinite combinations.
type Required<T> = { [K in keyof T]-?: T[K] };
type Readonly<T> = { readonly [K in keyof T]: T[K] };
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
interface User { id?: string; name: string }
type Solid = Required<User>; // { id: string; name: string }
type Locked = Readonly<User>; // { readonly id?: string; readonly name: string }
type Editable = Mutable<Locked>; // { id?: string; name: string } - in front = remove. it works
the same for ? and readonly. the standard
library's Required uses -? to strip
optional. Mutable isn't shipped, but it's a one-liner
you'll write at some point.
Pick and Omit
Pick is a mapped type whose key set is a subset.
Omit is Pick over the complement
of a key set, computed with Exclude. both end up doing
the obvious thing — which is the point: the standard tools are
short enough to read in one glance.
type Pick<T, K extends keyof T> = { [P in K]: T[P] };
interface User { id: string; name: string; age: number; passwordHash: string }
type SafeUser = Pick<User, "id" | "name">;
// ^? { id: string; name: string } // Omit = Pick with a complementary key set.
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
interface User { id: string; name: string; passwordHash: string }
type SafeUser = Omit<User, "passwordHash">;
// ^? { id: string; name: string } key remapping with as
until 4.1, mapped types could only re-type values, not rename keys.
then the as clause arrived and turned mapped types
into the swiss army knife they are today. every key passes through
the as expression, and the result becomes the new key.
// the 'as' clause maps each key through a type-level transform.
type Getters<T> = {
[K in keyof T & string as `get${Capitalize<K>}`]: () => T[K];
};
interface User { id: string; name: string }
type UserGetters = Getters<User>;
// ^? { getId: () => string; getName: () => string }
the same trick lets you filter: map a key to
never and it drops out of the result entirely.
never in a key position is the type-system's silent
continue.
// drop keys whose value isn't a function.
type Methods<T> = {
[K in keyof T as T[K] extends (...a: any[]) => any ? K : never]: T[K];
};
interface Service {
url: string;
timeout: number;
fetch(): Promise<unknown>;
cancel(): void;
}
type Api = Methods<Service>;
// ^? { fetch(): Promise<unknown>; cancel(): void } check yourself
mapped types — 4 questions
trace the mapping. pay attention to modifiers and to how 'as never' silently drops keys.
interface Form {
id?: string;
name?: string;
age?: number;
}
type Submitted = { [K in keyof Form]-?: Form[K] };
// ^?what is `Submitted`?
type EventMap = { click: MouseEvent; focus: FocusEvent };
type Handlers = {
[K in keyof EventMap as `on${Capitalize<K & string>}`]: (e: EventMap[K]) => void;
};
// ^?what is `Handlers`?
interface T {
a: string;
b: number;
c: string;
}
type StringKeys = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
// ^?what is `StringKeys`?
type Record<K extends keyof any, V> = { [P in K]: V };
type Status = "loading" | "ready" | "error";
type Counters = Record<Status, number>;
// ^?what is `Counters`?
three uses in the wild
patterns from real codebases
three production-shape uses of mapped types you'll write or read this year.
type FormState<T> = {
values: T;
errors: { [K in keyof T]?: string };
touched: { [K in keyof T]: boolean };
};
interface SignUp { email: string; password: string }
type S = FormState<SignUp>;// S = {
// values: { email: string; password: string };
// errors: { email?: string; password?: string };
// touched: { email: boolean; password: boolean };
// }
//
// add a field to SignUp, all three
// nested types update automatically.every form library worth using does exactly this. fields stay in sync with the source-of-truth shape.
type Endpoints = {
getUser: { input: { id: string }; output: { name: string } };
listOrders: { input: void; output: { id: string }[] };
};
type Client = {
[K in keyof Endpoints]: (
input: Endpoints[K]["input"]
) => Promise<Endpoints[K]["output"]>;
};// Client = {
// getUser: (i: { id: string }) => Promise<{ name: string }>;
// listOrders: (i: void) => Promise<{ id: string }[]>;
// }
//
// add an endpoint, get a typed
// client method for free.one declaration of endpoints generates the entire typed client surface. trpc-style, but homegrown in three lines.
type Events = {
"user:created": { id: string };
"user:deleted": { id: string };
"order:paid": { orderId: string; amount: number };
};
type Bus = {
on<E extends keyof Events>(
event: E,
cb: (payload: Events[E]) => void
): void;
emit<E extends keyof Events>(
event: E,
payload: Events[E]
): void;
};// bus.on("user:created", p => p.id); OK
// bus.on("order:paid", p => p.amount); OK
// bus.emit("user:created", { id: "u" });OK
// bus.emit("order:paid", { id: "u" }); ERROR
// ↳ missing "orderId" and "amount"the indexed lookup <code>Events[E]</code> threads the payload type through every callsite. event-bus typos vanish.
recursive mapping
mapped types can call themselves on nested values to walk deep into
a shape. DeepReadonly is the classic. it's three lines
and it's the same idea as DeepPartial from the previous
lesson — a base case, a recursive case, no ceremony.
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
interface Config {
server: { host: string; port: number };
flags: { ai: boolean };
}
type Frozen = DeepReadonly<Config>;
// ^? {
// readonly server: { readonly host: string; readonly port: number };
// readonly flags: { readonly ai: boolean };
// } what you took away
-
a mapped type is a for-loop over keys.
[K in keyof T]with optional value-side transformation produces the next utility type you were about to write. -
four modifier knobs —
?,-?,readonly,-readonly— let you add or remove optionality and immutability per-key. -
asremaps keys. combined with template literals it renames them (onClick); combined with conditional types andneverit filters them. -
the standard library's utilities (
Partial,Pick,Record) are one-liners. read them; they're the textbook examples. - recursion works on mapped types too, and it's the right shape for any tree-shaped transform: deep partial, deep readonly, sealed config.
further reading
- typescript handbook · mapped types — the spec, with the four modifiers and the as-clause.
- lib/es5.d.ts — search for "Partial", "Required", "Pick", "Record". short, exemplary.