映射类型:变换对象结构,实现 DeepPartial
第9章:映射类型:变换对象结构,实现 DeepPartial
理解映射类型是掌握 TypeScript 类型系统的关键一步。
本章核心问题:如何在实际项目中正确使用变换对象结构,实现 DeepPartial?关键的设计决策和陷阱是什么?
读完本章你将理解:
- 映射类型的语法核心
- 修饰符:添加和移除 readonly / ?
- 用
as重映射键名
Level 1 · 你需要知道的(1-3年经验)
映射类型的语法核心
映射类型让你基于已有类型批量生成新类型,就像对象的 .map() 方法——但作用于类型层面。
// 基础语法:遍历 T 的每个键,生成新类型
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 是核心——keyof T 产生 T 所有键的联合类型,in 遍历它们。
修饰符:添加和移除 readonly / ?
映射类型可以给每个属性添加或移除 readonly 和可选标记 ?。
// 添加 readonly(+readonly 等同于 readonly)
type Immutable<T> = {
+readonly [K in keyof T]: T[K];
};
// 移除 readonly(解冻类型)
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
// 添加可选标记
type Optional<T> = {
[K in keyof T]+?: T[K];
};
// 移除可选标记(所有字段变为必填)
type Required<T> = {
[K in keyof T]-?: T[K];
};
实际对比:
interface Config {
readonly host: string;
port?: number;
timeout: number;
}
type MutableConfig = Mutable<Config>;
// { host: string; port?: number; timeout: number }
// host 不再是 readonly
type AllRequired = Required<Config>;
// { host: string; port: number; timeout: number }
// port 不再是可选
用 as 重映射键名
TypeScript 4.1 引入了 as 子句,允许在映射时变换键名。
// 把所有键转为 getter 方法名
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 是必要的——K 的类型是 string | number | symbol,而 Capitalize 只接受 string,取交集后过滤掉非字符串键。
用 as ... never 过滤键
当 as 子句返回 never 时,该键从结果类型中消失。
// 只保留值为特定类型的键
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 }
Level 2 · 它是怎么运行的(3-5年经验)
实用映射类型示例
Nullable<T>:所有字段加 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>:从接口生成事件名
interface AppEvents {
userLogin: { userId: string };
dataLoaded: { count: number };
errorOccurred: { message: string };
}
type EventNames<T> = {
[K in keyof T as `on${Capitalize<string & K>}`]: (payload: T[K]) => void;
};
type AppHandlers = EventNames<AppEvents>;
// {
// onUserLogin: (payload: { userId: string }) => void;
// onDataLoaded: (payload: { count: number }) => void;
// onErrorOccurred: (payload: { message: string }) => void;
// }
主项目:实现 DeepPartial<T>
标准库的 Partial<T> 只有一层——嵌套对象不受影响。
interface FormData {
user: {
name: string;
address: {
city: string;
zip: string;
};
};
settings: {
theme: "light" | "dark";
notifications: boolean;
};
}
// 标准 Partial — 只有顶层变为可选
type ShallowPartial = Partial<FormData>;
// {
// user?: {
// name: string; ← 内层仍然必填!
// address: { city: string; zip: string; }
// };
// settings?: { theme: ...; notifications: boolean; }
// }
第一步:识别递归场景
// 错误做法:无限递归无终止条件
type BadDeepPartial<T> = {
[K in keyof T]?: BadDeepPartial<T[K]>; // 基础类型也被递归,破坏 string/number
};
第二步:正确实现
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
T extends object 是关键:只有对象类型才递归,string、number、boolean 等直接返回自身。
第三步:验证
type PartialFormData = DeepPartial<FormData>;
// 合法:只更新深层字段
const patch: PartialFormData = {
user: {
address: {
city: "Shanghai", // zip 可以省略
},
// name 也可以省略
},
};
// API patch 请求的典型用法
async function updateForm(id: string, patch: DeepPartial<FormData>) {
const response = await fetch(`/api/forms/${id}`, {
method: "PATCH",
body: JSON.stringify(patch),
});
return response.json();
}
处理数组
// 数组中的元素也需要递归
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 } }>
主项目:实现 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"; // 错误:只读
// state.user = { ... }; // 错误:只读
对比表
| 操作 | 语法 | 效果 |
|---|---|---|
| 添加可选 | [K in keyof T]?: |
所有字段变可选 |
| 移除可选 | [K in keyof T]-?: |
所有字段变必填 |
| 添加只读 | readonly [K in keyof T]: |
所有字段只读 |
| 移除只读 | -readonly [K in keyof T]: |
取消只读限制 |
| 重命名键 | [K in keyof T as NewKey]: |
键名变换 |
| 过滤键 | [K in keyof T as ... ? K : never]: |
按条件删除键 |
| 递归变换 | T extends object ? {...} : T |
深层遍历 |
Level 3 · 规范怎么定义的(资深)
映射类型的 as 子句(TS 4.1)是键重映射(key remapping)的核心,它允许在遍历键时变换键名或过滤键。当 as 子句返回 never 时,该键被移除——这利用了 never 在映射类型中的消除语义。DeepPartial<T> 的递归终止条件 T extends object 至关重要:基础类型(string、number 等)不应被递归处理,否则会破坏它们的结构。数组需要特殊处理(T extends Array<infer Item>),因为数组也是 object。
Level 4 · 边界与陷阱(所有人)
反模式:过度使用映射类型
// ❌ 反模式:用映射类型做本可以用 Pick 完成的事
type ComplexPick<T, K extends keyof T> = {
[P in K]: T[P]; // 等同于标准库 Pick<T, K>,没有任何好处
};
// ✓ 直接使用 Pick
type UserPreview = Pick<User, "id" | "name">;
// ❌ 反模式:映射类型嵌套三层以上,可读性崩溃
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];
};
// 如果真的需要这种变换,分成多个命名类型更易读
本章小结
| 概念 | 关键点 |
|---|---|
[K in keyof T] |
遍历所有键,是映射类型的基础 |
+? / -? |
添加或移除可选标记 |
-readonly |
移除只读限制(标准库 Mutable 的实现方式) |
as NewKey |
映射时重命名键,返回 never 则删除该键 |
DeepPartial<T> |
递归映射 + extends object 做基础类型保护 |
DeepReadonly<T> |
数组用 ReadonlyArray,对象递归加 readonly |
下一章预告
第 10 章讲模板字面量类型——把字符串运算搬进类型层面。你将看到如何从路由字符串 "/users/:id/posts/:postId" 中自动提取参数类型,以及如何构建类型安全的 i18n 键系统。