TypeScript 映射类型入门:让类型跟着数据结构一起变化
年前把中后台配置平台里几个重复的类型定义翻出来重整时,我干脆停下来把 Partial、Pick、Record 这些工具类型的实现挨个抄进一个空文件,逐行拆开看它们到底是怎么写出来的。之前一直是拿来就用,从没认真想过它们凭什么能自动跟着源类型变。
抄到一半我就明白了:这几个内置工具类型没有一个是"魔法",它们全都建立在同一个语法结构上——映射类型。把这个结构吃透,那些看着高级的工具类型立刻就变得平平无奇了。
映射类型解决的核心问题是重复。一个数据模型改了,围着它长出来的一堆相关类型也要跟着改,忘了同步,类型就和真实业务慢慢脱节。它的思路是:不手动复制一份类型,而是基于已有类型推导出新类型,源结构一变,相关类型自动跟着变。
我以前只把它当成 TypeScript 的"高级语法",做表单和配置平台做多了才发现它真正省的是维护成本。后端模型、表单草稿、更新接口、展示字段、权限开关,这些结构经常长得很像又不完全一样,每份都手写,字段一多就容易漏。所以下面记的不是怎么写复杂类型,而是一个更实用的判断:同一个业务模型的不同视角,尽量让类型关系可推导。
先把 keyof 和索引访问验证一遍
keyof 拿到一个对象类型的所有 key。这一步我是真在编辑器里敲进去、把鼠标悬在类型上看提示,确认它推导出来的确实是我以为的那个联合类型。
1type User = { 2 name: string; 3 age: number; 4}; 5 6type UserKey = keyof User;
UserKey 等于 "name" | "age"。有了 key 的联合类型,就能继续基于这些 key 生成新类型。
keyof 最常见的落地是约束函数参数:
1function getValue<T, K extends keyof T>(target: T, key: K): T[K] { 2 return target[key]; 3} 4 5const user = { 6 name: 'Tom', 7 age: 18, 8}; 9 10const name = getValue(user, 'name');
K extends keyof T 保证 key 必须来自对象本身,T[K] 表示对应 key 的值类型。我特意把 getValue(user, 'nick') 也敲进去,编辑器立刻标红,这就验证了约束确实生效。理解了这两个语法,映射类型会自然很多。
这个写法在配置平台里到处都是。表格列配置要声明 dataIndex,如果直接写成 string,字段名写错了都不报错。用 keyof 约束后,字段一改名,类型系统第一时间提醒。
1type Column<T> = { 2 title: string; 3 dataIndex: keyof T; 4}; 5 6const userColumns: Column<User>[] = [ 7 { title: '姓名', dataIndex: 'name' }, 8 { title: '年龄', dataIndex: 'age' }, 9];
映射类型的基本模板
映射类型能遍历一个类型的 key,生成新的属性。
1type ReadonlyUser = { 2 readonly [Key in keyof User]: User[Key]; 3};
我把这段贴进编辑器,展开后确实是:
1type ReadonlyUser = { 2 readonly name: string; 3 readonly age: number; 4};
关键在于,如果以后 User 新增 email,ReadonlyUser 会自动跟着变。这一点我也验了:在 User 里加一行 email: string,鼠标悬到 ReadonlyUser 上,email 立刻出现在里面,一个字都不用改。
映射类型的基本模板可以记成:
1type NewType<T> = { 2 [Key in keyof T]: T[Key]; 3};
Key in keyof T 负责遍历属性名,T[Key] 负责取属性值类型。后面加 readonly、?、重命名 key,都是在这个模板上做变化。
它最有价值的地方不是少写几行代码,而是把"这两个类型有关联"这件事表达出来。以后改 User,不需要靠人记得同步 ReadonlyUser、UserDraft、UserValidators。
内置工具类型都是映射类型
回到我最初那份"抄源码"的笔记。Partial 就是一个映射类型:
1type MyPartial<T> = { 2 [Key in keyof T]?: T[Key]; 3};
作用是把所有属性变成可选。
1type UserDraft = MyPartial<User>;
适合编辑表单、更新接口参数这类场景。但它不能乱用。创建用户接口如果明确要求 name 必填,就别直接把 payload 写成 Partial<User>,它太宽,会把不该可选的字段也放宽。更稳的是先区分业务动作:
1type CreateUserPayload = Pick<User, 'name' | 'age'>; 2type UpdateUserPayload = Partial<Pick<User, 'name' | 'age'>>;
这样类型能表达真实约束:创建必须给核心字段,更新可以只传变化字段。
和它相对的是 Required:
1type MyRequired<T> = { 2 [Key in keyof T]-?: T[Key]; 3};
-? 表示移除可选修饰符。同理 -readonly 移除只读修饰符:
1type Mutable<T> = { 2 -readonly [Key in keyof T]: T[Key]; 3};
这两个减号修饰符我一开始没注意,是抄 Required 源码时才发现的,它让我们能在类型层面批量调整属性规则。
Pick、Record、Omit 各自的角色
Pick 从一个类型里挑出部分字段:
1type MyPick<T, K extends keyof T> = { 2 [Key in K]: T[Key]; 3}; 4 5type UserBase = MyPick<User, 'name'>;
Record 根据一组 key 生成同一种值类型:
1type Role = 'admin' | 'editor' | 'guest'; 2 3type RolePermission = Record<Role, boolean>;
它等价于:
1type RolePermission = { 2 admin: boolean; 3 editor: boolean; 4 guest: boolean; 5};
Omit 可以理解成先排除一部分 key,再 Pick 剩余字段。业务里常用它从完整模型去掉不该提交的字段:
1type CreateUserPayload = Omit<User, 'id' | 'createdAt'>;
这些工具类型背后都离不开 keyof、映射类型和条件类型。
Record 在权限、字典和状态映射里尤其好用。订单状态和展示文案必须一一对应,用 Record 能避免漏配:
1type OrderStatus = 'pending' | 'paid' | 'cancelled'; 2 3const statusText: Record<OrderStatus, string> = { 4 pending: '待支付', 5 paid: '已支付', 6 cancelled: '已取消', 7};
如果后面新增 refunded,这里会直接报错,提醒你补文案。这个提醒比线上看到一个空状态要早得多。
重新映射 key
TypeScript 还支持在映射时改 key 名称,这就是 as 子句。我读文档时特意把示例照抄了一遍再改。
1type Permissions<T> = { 2 [Key in keyof T as `canChange${Capitalize<string & Key>}`]: boolean; 3};
传入:
1type Config = { 2 username: string; 3 layout: string; 4};
得到:
1type ConfigPermissions = { 2 canChangeUsername: boolean; 3 canChangeLayout: boolean; 4};
这种能力很适合把业务模型转换成权限、表单、校验器这类衍生类型。在配置平台里我更常见的用法是把字段模型转成权限开关:某些字段只有管理员能编辑,就让权限对象跟着字段走,而不是手写一份容易漏的配置。
as 除了改名还能过滤 key——把某个 key 映射成 never,它就会从结果里消失。比如只保留字符串字段:
1type StringFieldKeys<T> = { 2 [Key in keyof T]: T[Key] extends string ? Key : never; 3}[keyof T]; 4 5type UserStringKeys = StringFieldKeys<User>;
最后的 [keyof T] 是把映射后的对象类型再取值,得到一个联合类型。这个写法刚看会绕,我是把中间结果单独抽出来看了一眼才想通:先得到一个每个值是 Key | never 的对象,再用 [keyof T] 把所有值取成联合,never 在联合里自动消失。工具类型里这个模式很常见。
satisfies 配合映射类型做配置校验
satisfies 是 4.9 带来的操作符,前一年年底就发布了,这会儿全年可用。它和映射类型是绝配。它不会把对象强行收窄成目标类型,但会检查结构是否满足约束:
1const validators = { 2 name: (value: string) => (value ? null : '请输入用户名'), 3} satisfies Validators<User>;
这类写法很适合"配置对象要被检查,但又希望保留字面量推断"的场景。配在一起还能强制配置不漏字段,同时保留每一项自己的字面量信息:
1type FieldMeta<T> = { 2 [Key in keyof T]: { 3 label: string; 4 required?: boolean; 5 }; 6}; 7 8const userFields = { 9 name: { label: '姓名', required: true }, 10 age: { label: '年龄' }, 11} satisfies FieldMeta<User>;
如果 User 新增 email,这里报错,提醒你补展示配置。它比 const userFields: FieldMeta<User> = ... 舒服的地方在于:对象本身的字面量推断还在,后续要读 required: true 这类更窄的信息,不会被过早抹成普通 boolean。这一点我也在编辑器里比对过两种写法的悬浮提示,差别很明显。
映射类型适合哪些业务
映射类型很适合处理"同一个数据模型的不同视角":表单草稿让所有字段可选,详情展示让所有字段只读,更新接口只允许提交部分字段,权限配置给每个字段配一个开关,校验规则给每个字段配一个校验函数。
比如校验规则:
1type Validators<T> = { 2 [Key in keyof T]?: (value: T[Key]) => string | null; 3}; 4 5const userValidators: Validators<User> = { 6 name(value) { 7 return value ? null : '请输入用户名'; 8 }, 9};
name 校验函数拿到的是 string,age 校验函数拿到的是 number。类型跟着字段走,减少手动维护。类似的还有表单状态:
1type FieldState<T> = { 2 [Key in keyof T]: { 3 value: T[Key]; 4 touched: boolean; 5 error?: string; 6 }; 7};
如果 User.age 是 number,那么 FieldState<User>['age'].value 也是 number。这种类型关系能让表单封装更可靠,尤其是字段很多的中后台页面。
更新接口也适合用映射类型约束"只能提交可修改字段"。别直接 Partial<User>,它可能把 id、createdAt、role 这类不该由前端改的字段也放进去:
1type User = { 2 id: string; 3 name: string; 4 age: number; 5 role: 'admin' | 'user'; 6 createdAt: string; 7}; 8 9type EditableUserField = 'name' | 'age'; 10type UpdateUserPayload = Partial<Pick<User, EditableUserField>>; 11 12function updateUser(id: string, payload: UpdateUserPayload) { 13 return request.patch(`/users/${id}`, payload); 14} 15 16updateUser('1', { name: 'Tom' }); 17updateUser('1', { role: 'admin' }); // 报错,role 不允许从这个接口改
这个约束看着小,对后台系统很实用。前端页面上可能拿着完整 User,提交时随手把整对象丢给接口,轻则传一堆无用字段,重则把权限、创建时间这类敏感字段也带上。类型提前收窄,能把这种风险压在开发阶段。
一个坑:分布式条件类型里的裸类型参数
抄工具类型时我还撞上一个自己没预料的现象。写一个"排除 null 字段"的工具时,我以为映射加条件就够了,结果发现条件类型作用在联合类型上会自动"分发",行为和我脑子里想的不一样。
1type NonNullableValues<T> = { 2 [Key in keyof T]: NonNullable<T[Key]>; 3};
这个还好,映射的每个值单独走一次条件判断,符合直觉。但如果我把条件直接写在一个裸的类型参数上,比如 T extends U ? ... : ... 而 T 是联合类型,它会对联合的每一项分别计算再合起来。要阻止分发,得把两边都用元组包起来:[T] extends [U] ? ... : ...。我是把两种写法的结果都悬浮出来对比,才确认自己没记错。映射类型本身不涉及分发,但它经常和条件类型一起用,这个区别不搞清楚,工具类型的输出会莫名其妙。
不要为了复杂而复杂
映射类型很强,也容易写过头。如果一个类型工具要嵌套多层条件、递归和模板字符串,团队里大多数人都看不懂,它可能会成为维护负担。类型系统应该帮着表达业务约束,而不是把简单问题变成类型体操。
我会优先把常用工具封装成清楚的名字:
1type FormDraft<T> = Partial<T>; 2type ReadonlyModel<T> = Readonly<T>;
名字清楚,比每次把复杂映射类型直接写在业务代码里更好。如果确实需要写复杂工具类型,我会先问两个问题:这个工具类型是否解决了真实的重复维护问题,团队里其他人能不能在几分钟内理解它的输入和输出。答案是否定的,宁愿写得朴素一点。
这次把内置工具类型逐个抄下来验证之后,我写映射类型会先确认它是不是真能减少维护成本。表单草稿、权限开关、接口更新 payload 这类结构确实经常和原始模型强相关,用 Partial、Pick、Omit、Record 或自己包一层清楚的工具类型,比手写多份字段稳得多。字段一改,相关类型跟着走,这就是它最实在的价值。先把 keyof、T[Key]、[Key in keyof T] 这几块吃透,再按真实重复场景去抽工具类型,通常就够用了。