Mapped Types
Build new types by transforming every key of an existing type - the mechanism behind Partial, Readonly, and the rest of the TypeScript stdlib.
Mapped types iterate over the keys of an existing type and produce a new type with transformed values or modifiers. They are how TypeScript's standard utility types (Partial, Readonly, Required) are implemented - not compiler magic, but plain TypeScript you can write yourself.
The { [K in keyof T]: ... } syntax creates a new type with the same keys as T but with transformed values. keyof T produces a union of all key names; K is the loop variable; the value on the right determines the new property type.
type User = {
id: string;
name: string;
email: string;
};
// Every value becomes an array of that type
type Listify<T> = {
[K in keyof T]: T[K][];
};
type UserLists = Listify<User>;
// { id: string[]; name: string[]; email: string[] }
// Wrap every value in a Promise
type Asyncify<T> = {
[K in keyof T]: Promise<T[K]>;
};
type AsyncUser = Asyncify<User>;
// { id: Promise<string>; name: Promise<string>; email: Promise<string> }Modifier prefixes control readonly and optionality. + adds the modifier (the default when you omit it); - removes it. This lets you strip all readonly constraints or make every optional property required.
type Config = {
readonly host: string;
readonly port: number;
debug?: boolean;
};
// -readonly: strip all readonly modifiers
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
type MutableConfig = Mutable<Config>;
// { host: string; port: number; debug?: boolean }
// -?: make every optional property required
type Complete<T> = {
[K in keyof T]-?: T[K];
};
type RequiredConfig = Complete<Config>;
// { readonly host: string; readonly port: number; debug: boolean }Key remapping via as lets you rename keys as part of the mapped type. Combine with template literal types to derive new names - for example, converting every field into a getter method signature.
type Model = {
id: string;
name: string;
createdAt: Date;
};
// Remap key names: prepend "get" and capitalize
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type ModelGetters = Getters<Model>;
// {
// getId: () => string;
// getName: () => string;
// getCreatedAt: () => Date;
// }
// Filter keys with `as` + `never` - omit all Function-valued properties
type DataOnly<T> = {
[K in keyof T as T[K] extends Function ? never : K]: T[K];
};A practical example: Versioned<T> adds a _version field to any entity for optimistic-locking. Enforcing it at the type level means the version field is present everywhere it's required - without repeating it in every entity definition.
type Versioned<T> = T & { _version: number };
type Product = { id: string; name: string; price: number };
type Order = { id: string; items: string[]; total: number };
type VersionedProduct = Versioned<Product>;
type VersionedOrder = Versioned<Order>;
function update<T extends { id: string }>(
entity: Versioned<T>,
patch: Partial<T>
): Versioned<T> {
return { ...entity, ...patch, _version: entity._version + 1 };
}
const product: VersionedProduct = {
id: "p1", name: "Widget", price: 9.99, _version: 1,
};
const updated = update(product, { price: 12.99 });
console.log(updated._version); // 2In production
Readonly<T> and Partial<T> are stdlib mapped types - not compiler magic. Writing your own lets you enforce constraints at the type level without runtime overhead. A Versioned<T> that adds _version to any entity ensures the optimistic-lock field is required at every persistence boundary. Key remapping with as combined with never is the idiomatic way to filter keys - producing a narrowed type that only exposes the fields you want (e.g., stripping all Function properties for a serializable snapshot). If you find yourself writing the same optional/required/readonly transformation more than once, write a generic mapped type once and reuse it.
Enjoyed this? Get more essays on software craft delivered to your inbox.
Subscribe free