Mapped Types: Transforming Object Shapes, DeepPartial
The Core Syntax
A mapped type generates a new type by iterating over the keys of an existing type — the type-level equivalent of Array.map.
// Basic syntax: iterate every key of T, produce a new type
type Copy<T> = {
[K in keyof T]: T[K];
};
interface User {
id: number;
name: string;
email: string;
}
type UserCopy = Copy<User>;
// { id: number; name: string; email: string }
K in keyof T is the engine: keyof T produces a union of all keys, and in iterates over them.
Modifiers: Adding and Removing readonly / ?
Mapped types can add or remove readonly and optional ? from every property.
// Add readonly (the + prefix is optional)
type Immutable<T> = {
+readonly [K in keyof T]: T[K];
};
// Remove readonly (unfreeze a type)
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
// Add optional
type Optional<T> = {
[K in keyof T]+?: T[K];
};
// Remove optional (all fields become required)
type Required<T> = {
[K in keyof T]-?: T[K];
};
In practice:
interface Config {
readonly host: string;
port?: number;
timeout: number;
}
type MutableConfig = Mutable<Config>;
// { host: string; port?: number; timeout: number }
// host is no longer readonly
type AllRequired = Required<Config>;
// { host: string; port: number; timeout: number }
// port is no longer optional
Remapping Keys with as
TypeScript 4.1 added the as clause — you can transform key names during iteration.
// Convert every key to a getter method name
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface Person {
name: string;
age: number;
}
type PersonGetters = Getters<Person>;
// {
// getName: () => string;
// getAge: () => number;
// }
string & K is necessary: K has type string | number | symbol, but Capitalize only accepts string. The intersection filters out non-string keys.
Filtering Keys with as ... never
When the as clause evaluates to never, that key is dropped from the result type.
// Keep only keys whose value extends a certain type
type PickByValue<T, V> = {
[K in keyof T as T[K] extends V ? K : never]: T[K];
};
interface Mixed {
id: number;
name: string;
active: boolean;
score: number;
}
type StringKeys = PickByValue<Mixed, string>;
// { name: string }
type NumberKeys = PickByValue<Mixed, number>;
// { id: number; score: number }
Practical Mapped Types
Nullable<T>: Make All Fields Accept null
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
type NullableUser = Nullable<User>;
// { id: number | null; name: string | null; email: string | null }
EventMap<T>: Generate Handler Names from an Interface
interface AppEvents {
userLogin: { userId: string };
dataLoaded: { count: number };
errorOccurred: { message: string };
}
type EventHandlers<T> = {
[K in keyof T as `on${Capitalize<string & K>}`]: (payload: T[K]) => void;
};
type AppHandlers = EventHandlers<AppEvents>;
// {
// onUserLogin: (payload: { userId: string }) => void;
// onDataLoaded: (payload: { count: number }) => void;
// onErrorOccurred: (payload: { message: string }) => void;
// }
Main Project: Implementing DeepPartial<T>
The standard library's Partial<T> is shallow — nested objects remain fully required.
interface FormData {
user: {
name: string;
address: {
city: string;
zip: string;
};
};
settings: {
theme: "light" | "dark";
notifications: boolean;
};
}
// Standard Partial — only the top level becomes optional
type ShallowPartial = Partial<FormData>;
// {
// user?: {
// name: string; ← still required inside!
// address: { city: string; zip: string; }
// };
// settings?: { theme: ...; notifications: boolean; }
// }
Step 1: Identify the Recursive Case
// Wrong approach: no base case — primitive types get recursed, breaking them
type BadDeepPartial<T> = {
[K in keyof T]?: BadDeepPartial<T[K]>; // string becomes BadDeepPartial<string> — broken
};
Step 2: Correct Implementation
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
T extends object is the guard: only object types recurse. string, number, boolean, etc., are returned as-is.
Step 3: Verify It Works
type PartialFormData = DeepPartial<FormData>;
// Valid: update only a deep field
const patch: PartialFormData = {
user: {
address: {
city: "Shanghai", // zip can be omitted
},
// name can also be omitted
},
};
// Typical usage in API PATCH requests
async function updateForm(id: string, patch: DeepPartial<FormData>) {
const response = await fetch(`/api/forms/${id}`, {
method: "PATCH",
body: JSON.stringify(patch),
});
return response.json();
}
Handling Arrays
// Elements inside arrays also need recursion
type DeepPartialWithArray<T> = T extends Array<infer Item>
? Array<DeepPartialWithArray<Item>>
: T extends object
? { [K in keyof T]?: DeepPartialWithArray<T[K]> }
: T;
interface Catalog {
products: Array<{
id: number;
name: string;
specs: { weight: number; color: string };
}>;
}
type PatchCatalog = DeepPartialWithArray<Catalog>;
// products: Array<{ id?: number; name?: string; specs?: { weight?: number; color?: string } }>
Main Project: Implementing DeepReadonly<T>
type DeepReadonly<T> = T extends Array<infer Item>
? ReadonlyArray<DeepReadonly<Item>>
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
interface AppState {
user: {
id: string;
preferences: {
language: string;
theme: string;
};
};
cache: Map<string, unknown>;
}
type FrozenState = DeepReadonly<AppState>;
declare const state: FrozenState;
// state.user.preferences.language = "zh"; // Error: read-only
// state.user = { ... }; // Error: read-only
Anti-Pattern: Over-Engineering with Mapped Types
// ❌ Anti-pattern: using a mapped type where Pick already exists
type ComplexPick<T, K extends keyof T> = {
[P in K]: T[P]; // identical to Pick<T, K> — no benefit
};
// ✓ Just use Pick
type UserPreview = Pick<User, "id" | "name">;
// ❌ Anti-pattern: three or more levels of nesting destroys readability
type OverEngineered<T> = {
[K in keyof T as K extends string
? `__${Uppercase<K>}__`
: never]: T[K] extends object
? { [P in keyof T[K]]: T[K][P] extends string ? `[${T[K][P]}]` : T[K][P] }
: T[K];
};
// Split into named intermediate types instead
Comparison Table
| Operation | Syntax | Effect |
|---|---|---|
| Add optional | [K in keyof T]?: |
All fields become optional |
| Remove optional | [K in keyof T]-?: |
All fields become required |
| Add readonly | readonly [K in keyof T]: |
All fields become readonly |
| Remove readonly | -readonly [K in keyof T]: |
Readonly removed |
| Rename keys | [K in keyof T as NewKey]: |
Key name transformation |
| Filter keys | [K in keyof T as ... ? K : never]: |
Conditional key removal |
| Recursive transform | T extends object ? {...} : T |
Deep traversal |
Chapter Summary
| Concept | Key Point |
|---|---|
[K in keyof T] |
Iterates all keys — the foundation of mapped types |
+? / -? |
Add or remove the optional modifier |
-readonly |
Remove readonly (how the standard Mutable pattern works) |
as NewKey |
Rename keys during mapping; returning never drops the key |
DeepPartial<T> |
Recursive mapping + extends object guard for primitives |
DeepReadonly<T> |
Arrays use ReadonlyArray; objects recurse with readonly |
What's Next
Chapter 10 covers template literal types — moving string operations into the type system. You'll see how to extract parameters automatically from a route string like "/users/:id/posts/:postId" and build a type-safe i18n key system.