为什么需要 Mapped Types 高级用法
在真实业务中,前端拿到的接口数据往往和后端定义的协议不完全一致。比如后端返回的字段是 snake_case,前端组件内部更习惯 camelCase;或者同一个实体在列表、表单、详情三个场景里字段数量不同,需要裁剪掉一部分键。如果每次都手工写工具函数挨个转换,类型定义就会渐渐失真,改一个字段要同步好几处。

TypeScript 的 Mapped Types(映射类型)就是为这类“类型层面的变换”设计的。基础的映射类型大家可能都用过,比如 type Readonly<T> = { readonly [P in keyof T]: T[P] }。但真正让映射类型强大起来的,是它配合 as 子句做的键重映射,以及用 never 做的条件过滤。这两样组合起来,几乎可以把一个类型改写成任意形态。
这篇文章就围绕 Mapped Types 的键重映射与条件过滤展开,先理清语法,再看应用,最后聊几个容易踩的坑。
基础映射类型回顾:in 与 keyof
映射类型的核心语法是 { [K in keyof T]: 某种新类型 }。它遍历 T 的所有键,并把每个键映射到新的值类型。比如把一个接口的每个属性都变成 string:
type Stringify<T> = { [K in keyof T]: string };
interface User {
id: number;
name: string;
age: number;
}
type StringifyUser = Stringify<User>;
// { id: string; name: string; age: string }
这种基础映射只能改值类型,键本身没法动。如果我们要给每个键加前缀,或者在定义中排除某些键,就必须借助 as 子句。
键重映射:as 子句的真正用法
从 TypeScript 4.1 开始,映射类型支持在键上使用 as 子句,也就是说可以重新定义遍历出来的键名。语法如下:
type AddPrefix<T, P extends string> = {
[K in keyof T as `${P}_${K}`]: T[K]
};
这里 as 后面可以是一个模板字符串类型,也可以是一个条件类型。重映射后,新对象类型的键就不再是原来的 K,而是 as 表达式计算出来的结果。
这个能力极其实用。比如开发一个 SDK 时,希望把内部接口的所有方法统一加上一个统一前缀,避免污染全局页面上下文,就可以写一个这样的类型工具。又比如把后端返回的 user_id 这类字段映射成 userId,本质上也是在重命名键。
把 snake_case 键重映射为 camelCase
一个经典的例子是“大写蛇形键转小驼峰”。在类型层面实现一个 CamelizeKeys<T>:
type Camelize<S extends string> =
S extends `${infer First}_${infer Rest}`
? `${First}${Capitalize<Camelize<Rest>>}`
: S;
type CamelizeKeys<T> = {
[K in keyof T as Camelize<K>]: T[K]
};
type ApiResponse = {
user_name: string;
user_age: number;
is_active: boolean;
};
type NormalizedResponse = CamelizeKeys<ApiResponse>;
// { userName: string; userAge: number; isActive: boolean }
你不需要在每个业务模块里再手写一遍转换后的类型,只要定义一次,所有数据流都会自动使用同一个类型基准。这里也体现了 TypeScript 类型编程的一个特点:它描述的是变换规则,而不是结果。
条件过滤:用 as 子句把键筛选出来
键重映射还有一种常见形态:不改变键名,而是把某些键映射成 never,这样最终类型里这个键就会被直接过滤掉。因为 TypeScript 在生成对象类型时不会保留值为 never 的属性。写法很直接:
type ExcludeKeys<T, Excluded extends keyof T> = {
[K in keyof T as Excluded extends K ? never : K]: T[K]
};
这个工具把传入的键排除掉,剩下的键保持不变。比如前端拿到一个用户对象,但为了统一字段格式,想把 password 和 token 这类敏感字段从类型层剔除,避免被业务代码误用:
type PublicUser = ExcludeKeys<User, 'password' | 'token'>;
这里要注意条件类型分发的一个细节。上面的 Excluded extends K ? ... 中,Excluded 是一个联合类型,会触发条件类型的分发,但这里我们把它放在 as 子句的判断位置,实际上对每个 K 都要检查是否属于 Excluded。更安全的写法是把联合类型先包起来:
type ExcludeKeys<T, Excluded extends keyof T> = {
[K in keyof T as K extends Excluded ? never : K]: T[K]
};
注意 K extends Excluded 和 Excluded extends K 的区别:前者是“当前键被排除集合包含”,后者是“排除集合包含当前键”,放在 as 的 条件判断里效果相同,但语义上推荐前者,因为它对“当前键是否命中过滤规则”的表达更直接。
组合能力:重映射 + 属性修饰符
除了键名变换,映射类型还能同步处理属性修饰符,比如 readonly 和可选 ?。TypeScript 提供了 +/- 操作符来添加或移除修饰符。比如你想把所有属性变成 readonly 的同时,把键名改成大写开头:
type UpperKeys<T> = {
readonly [K in keyof T as Capitalize<K>]: T[K]
};
type Config = { host: string; port: number };
type Config2 = UpperKeys<Config>;
// { readonly Host: string; readonly Port: number }
这些修饰符操作可以和键重映射共存,但要注意顺序:先映射键,再处理修饰符。实际开发中,需要同时改键名和修饰符的场景不算特别多,但了解有这种能力后,遇到需求时就能想到“类型层可以做除了值类型变换之外的形态调整”。
真实场景:从 API 响应生成表单模型
假设你在做一个后台管理系统,接口返回的用户数据是下划线命名,而前端表单组件用的是 camelCase。不仅如此,列表页只需要展示部分字段,详情页又需要额外的字段。
传统做法是定义一个 flat 的 DTO 接口,再用映射类型把原始类型统一转换成表单模型。下面是一个简化的实现:
type ApiUser = {
user_id: number;
user_name: string;
user_password: string;
is_admin: boolean;
created_at: string;
};
// 1. 先过滤掉敏感字段
type SafeUser = ExcludeKeys<ApiUser, 'user_password' | 'created_at'>;
// 2. 再转换键名为 camelCase
type FormUser = CamelizeKeys<SafeUser>;
在代码里,你只需要维护一个原始接口类型,后续所有组件的 props、状态、发送给后端的 payload 都可以基于 FormUser 来精确约束。如果后端加了字段,只要原始类型更新,其他地方的编译检查就会跟着更新。
这也是 Mapped Types 高级用法最有价值的地方:它把类型变换变成了一种可组合的流程,而不是孤立的魔法。
常见误区与边界
很多团队刚接触这些语法时,容易遇到几个问题。
- 不是所有类型都能随意映射。 映射类型只能作用于对象类型,对
string、number、boolean这类基础类型没有意义,对array和tuple要谨慎处理,它们需要先转换成对象形式。比如{ [K in keyof string]: ... }虽然能编译,但结果非常奇怪。 - as 子句中的条件类型很容易触发分发。 如果不注意条件类型的分发规则,联合类型会在判断时被拆开,导致过滤结果不符合预期。
- 不要把类型层和运行时混淆。 用 Mapped Types 生成一个新类型,并不代表运行时真的会有这个对象。它是静态类型,不影响 JS 执行。
另外还有一个常见的认知偏差:认为所有类型变换都应该用 Mapped Types 来做。实际上,如果只是改两三个字段,手写一个接口反而更清晰。类型工具应服务于重复出现的模式,一两次的转换没必要套太复杂的泛型。
方案对比:什么时候该用什么
| 需求 | 推荐做法 | 复杂度 | 适用场景 |
|---|---|---|---|
| 只改值类型 | 基础映射 [K in keyof T]: NewType |
低 | 统一包装,比如全部转 string |
| 键名需要加前缀/后缀 | 模板字符串 + as 重映射 | 低 | 给 API 字段加业务前缀 |
| 键名需要大小写转换 | 字符串递归转换 + as 重映射 | 中 | snake_case 与 camelCase 互相转换 |
| 过滤掉某些字段 | as 子句 + never | 中 | 敏感字段、列表示例、裁剪对象 |
| 键和值都需要组合变换 | 重映射 + 条件类型 + 递归 | 高 | 复杂接口版本的迁徙桥接 |
落地建议
如果要在真实项目里用起来,可以按这个思路来演进。
- 先从最常见、收益最大的转换开始:把后端返回类型的键命名统一,比如把所有下划线键转成 camelCase。这能立刻减少业务代码里
.user_name和.userName混用的碎片。 - 用一套基础工具类型文件集中管理这些映射,不要散落在各个组件里。命名前缀可以统一为
TypeUtils比如CamelizeKeys、ExcludeKeys。 - 善用 IDE 的类型提示来验证结果。在写好的类型上悬停,TypeScript 会展开成最终的对象结构,能很快发现映射逻辑的问题。
- 如果项目很大,可以先在纯函数层验证类型是否匹配,避免直接在组件里用新的类型导致大量报错。
经验提醒:Mapped Types 是类型编程的一部分,不是玄学。建议在团队里留一份带注释的工具类型库,并写明每个工具在什么场景下才需要用到,避免后面维护的人产生“这是黑魔法”的恐惧。
小结
TypeScript 的 Mapped Types 高级用法,本质上是把“类型的变换”作为一等公民来对待。键重映射用 as 子句重新定义了对象类型的键集合,再配合 never 和条件类型,可以过滤、合并、包装一个对象类型。真正需要理解的是这些语法背后的思维:在类型层面描述规则的变换,而不是逐个字段手动创建接口。
回到开头的问题:当接口数据、前端模型、业务状态之间的字段映射越来越多时,手写接口不是不行,但会带来大量的维护成本。用 Mapped Types 把这些变换固化下来,每次修改只需动一处,剩下的交给编译器。
当然,类型编程也要有度。如果你的团队还不熟悉这个写法,建议先从一两个收益明显的地方入手,比如把后端的 snake_case 统一转成 camelCase。等大家适应了,再逐步扩展出过滤、前缀、修饰符这些重映射能力。
这篇文章没有聊到 infer 的更多技巧,也没有涉及 key remapping 与 distributive conditional types 的所有细节,但核心思路已经覆盖了:Mapped Types 可以让你在类型层做“键级别的筛选和重命名”。希望这些示例能成为你手头有用的工具。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/701/