TypeScript Mapped Types — Transform and Derive Types at Scale
Advertisement
Introduction
Why This Matters
Mapped types are TypeScript's most powerful tool for eliminating type duplication. Instead of copying an interface and manually making every field optional or readonly, you write a single mapped type and derive it automatically. The result is a single source of truth — change the base interface and all derived types update automatically.
For backend engineers, mapped types power essential patterns: creating DTO types from entity interfaces, building type-safe event listener registries, generating getter/setter pairs, and constructing validation schemas that mirror domain models. Built-in utility types like Partial, Readonly, and Record are all implemented as mapped types internally.
Understanding mapped types from first principles also lets you write your own utilities for domain-specific needs — deep partial configs, nullable database rows, or filterable API response shapes.
Basic Mapped Type Syntax
A mapped type iterates over the keys of an existing type using [K in keyof T].
interface User {
id: string;
name: string;
email: string;
age: number;
}
// Replicate the built-in Partial<T>
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
// Replicate the built-in Readonly<T>
type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};
// Make every property nullable
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
type NullableUser = Nullable<User>;
// { id: string | null; name: string | null; email: string | null; age: number | null }Modifier Addition and Removal
Mapped types can add or remove readonly and ? modifiers using + and - prefixes.
// Remove readonly from all properties
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
// Remove optional from all properties
type Complete<T> = {
[K in keyof T]-?: T[K];
};
interface PartialConfig {
host?: string;
port?: number;
debug?: boolean;
}
type RequiredConfig = Complete<PartialConfig>;
// { host: string; port: number; debug: boolean }
const config: RequiredConfig = {
host: 'localhost',
port: 3000,
debug: false,
};Key Remapping with as
TypeScript 4.1+ allows remapping keys using the as clause. This enables renaming, filtering, and template literal transformations.
// Generate getter method names from property names
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<User>;
// { getId: () => string; getName: () => string; getEmail: () => string; getAge: () => number }
// Generate setter method names
type Setters<T> = {
[K in keyof T as `set${Capitalize<string & K>}`]: (value: T[K]) => void;
};
// Filter properties — keys that map to never are removed
type StringProperties<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
type UserStringFields = StringProperties<User>;
// { name: string; email: string }
// (id excluded if it were number, age excluded as number)
// Filter out methods — keep only data properties
type DataProperties<T> = {
[K in keyof T as T[K] extends Function ? never : K]: T[K];
};Conditional Mapped Types
Combine mapped types with conditional types for powerful transformations.
// Make properties optional only if they match a certain type
type OptionalStrings<T> = {
[K in keyof T]: T[K] extends string ? T[K] | undefined : T[K];
};
// Create a validation error shape from any interface
type ValidationErrors<T> = {
[K in keyof T]?: string;
};
// Create a form state type from a data model
type FormState<T> = {
values: T;
errors: ValidationErrors<T>;
touched: Partial<Record<keyof T, boolean>>;
dirty: Partial<Record<keyof T, boolean>>;
};
interface LoginForm {
email: string;
password: string;
rememberMe: boolean;
}
type LoginFormState = FormState<LoginForm>;Recursive Mapped Types
// Deep partial — recursively make nested objects optional
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
// Deep readonly — freeze entire object tree
type DeepReadonly<T> = T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
interface AppConfig {
server: {
host: string;
port: number;
tls: {
enabled: boolean;
certPath: string;
};
};
database: {
url: string;
pool: { min: number; max: number };
};
}
// Override only deeply nested fields
const patch: DeepPartial<AppConfig> = {
database: {
pool: { max: 20 },
},
};
// Immutable config object
const frozenConfig: DeepReadonly<AppConfig> = {
server: { host: 'localhost', port: 3000, tls: { enabled: false, certPath: '' } },
database: { url: 'postgres://localhost/db', pool: { min: 2, max: 10 } },
};
// frozenConfig.database.pool.max = 5; // Error: readonlyReal-World Patterns
Type-Safe Event Registry
interface EventMap {
'user.created': { userId: string; email: string };
'user.deleted': { userId: string };
'order.placed': { orderId: string; total: number };
'payment.failed': { orderId: string; reason: string };
}
type EventListeners = {
[K in keyof EventMap]: (payload: EventMap[K]) => void | Promise<void>;
};
type EventRegistry = Partial<EventListeners>;
class TypedEventBus {
private listeners: Partial<EventListeners> = {};
on<K extends keyof EventMap>(event: K, handler: EventListeners[K]): void {
this.listeners[event] = handler as EventListeners[K];
}
async emit<K extends keyof EventMap>(event: K, payload: EventMap[K]): Promise<void> {
await this.listeners[event]?.(payload as Parameters<EventListeners[K]>[0]);
}
}API Mock Generator
type AsyncReturnType<T extends (...args: any[]) => Promise<any>> =
T extends (...args: any[]) => Promise<infer R> ? R : never;
type MockService<T extends Record<string, (...args: any[]) => Promise<any>>> = {
[K in keyof T]: jest.MockedFunction<T[K]>;
};Common Mistakes
- Using
keyof Twithout constraining tostringwhen template literal types require it — usestring & keyof T - Creating deeply nested recursive mapped types that slow TypeScript's compiler significantly
- Forgetting that keys remapped to
neverare silently dropped — this can produce empty types unexpectedly - Mixing mapped types with regular interface properties — keep them separate for clarity
- Over-engineering type transformations when a simple
PickorOmitwould suffice
Best Practices
- Start with built-in utility types (
Partial,Readonly,Record) before writing custom mapped types - Name custom mapped types after their transformation:
Nullable<T>,Mutable<T>,Stringified<T> - Use
askey remapping instead of manually recreating types with renamed properties - Test complex mapped types by assigning example values and checking hover types in your IDE
- Keep recursive mapped types bounded — add a depth limit or use them only for 2-3 nesting levels
Key Takeaways
- Mapped types iterate over
keyof Tto systematically transform every property of a type +?adds optional modifier,-?removes it;+readonlyadds readonly,-readonlyremoves it- The
asclause in mapped types enables key renaming, filtering withnever, and template literal generation [K in keyof T as T[K] extends SomeType ? K : never]filters properties by their value typeDeepPartial<T>andDeepReadonly<T>are recursive mapped types not available in the standard library- Built-in types
Partial,Required,Readonly, andRecordare all implemented as mapped types internally - Template literal types combined with
CapitalizegenerateonEvent,getField, andsetFieldkey patterns - Mapped types are compile-time only — they produce zero JavaScript output and have no runtime cost
Advertisement