Mapped types 是 TypeScript 类型系统里使用频率最高但理解深度最浅的特性之一。大多数人会用 Partial<T>、Required<T>、Readonly<T>,但很少想过这些内置工具类型是怎么实现的。实际 mapped types 不只能做这些基础变换——它还可以对键名做重映射、按值类型过滤键、给对象属性做加减乘除。
映射语法的基础
一个 mapped type 的基本结构是这样:
type Mapped<T> = {
[K in keyof T]: T[K]
}
[K in keyof T] 遍历 T 的所有键,右侧 T[K] 取对应值类型。这是最简单的恒等映射——输入什么类型就输出什么类型。
基于这个结构,可以添加各种修饰符:
type MyReadonly<T> = {
readonly [K in keyof T]: T[K]
}
type MyPartial<T> = {
[K in keyof T]?: T[K]
}
type MyRequired<T> = {
[K in keyof T]-?: T[K]
}
? 把属性变成可选,readonly 加上只读。减号 -? 和 -readonly 是去掉这些修饰符——内置的 Required<T> 和 Mutable<T> 就是靠减号实现的。
键重映射(Key Remapping)
TypeScript 4.1 引入的 as 子句让 mapped types 能重命名键,而不只是修改键上的类型:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
type Person = { name: string; age: number }
type PersonGetters = Getters<Person>
// { getName: () => string; getAge: () => number }
这里的 as \get${Capitalize<string & K>}`` 做了三件事:
string & K确保 K 是字符串(symbol和其他值类型的键被排除)Capitalize把首字母大写- 模板字面量拼接出
getXxx的键名
如果没有 as 子句,mapped types 只能改值类型,不能改键名。
按值类型过滤键
as 子句可以返回 never 来排除某些键:
type FunctionsOnly<T> = {
[K in keyof T as T[K] extends Function ? K : never]: T[K]
}
type Obj = { name: string; greet: () => void; age: number }
type FnProps = FunctionsOnly<Obj>
// { greet: () => void }
type NonFunctions<T> = {
[K in keyof T as T[K] extends Function ? never : K]: T[K]
}
type NonFnProps = NonFunctions<Obj>
// { name: string; age: number }
as never 意味着这个键不出现在输出类型里。这不是把值类型改成 never,而是完全剔除这个键。
前缀添加与移除
// 添加前缀
type AddPrefix<T, P extends string> = {
[K in keyof T as `${P}${string & K}`]: T[K]
}
type WithPrefix = AddPrefix<{ name: string }, 'user_'>
// { user_name: string }
// 移除前缀
type RemovePrefix<T, P extends string> = {
[K in keyof T as K extends `${P}${infer R}` ? R : never]: T[K]
}
type Unprefixed = RemovePrefix<{ user_name: string; user_age: number }, 'user_'>
// { name: string; age: number }
下划线转驼峰
结合模板字面量的递归,可以对键做大小写转换:
type SnakeToCamel<S extends string> =
S extends `${infer P}_${infer Q}`
? `${P}${Capitalize<SnakeToCamel<Q>>}`
: S
type CamelCaseKeys<T> = {
[K in keyof T as SnakeToCamel<string & K>]: T[K]
}
type SnakeObj = { first_name: string; last_name: string }
type CamelObj = CamelCaseKeys<SnakeObj>
// { firstName: string; lastName: string }
实用工具类型的实现
PickByValue:按值类型筛选
type PickByValue<T, V> = {
[K in keyof T as T[K] extends V ? K : never]: T[K]
}
type Obj2 = { a: string; b: number; c: string; d: boolean }
type OnlyString = PickByValue<Obj2, string>
// { a: string; c: string }
DeepPartial:递归可选化
type DeepPartial<T> = T extends object
? T extends Array<infer U>
? Array<DeepPartial<U>>
: {
[K in keyof T]?: DeepPartial<T[K]>
}
: T
type Config = { server: { host: string; port: number }; db: { url: string } }
type PartialConfig = DeepPartial<Config>
// { server?: { host?: string; port?: number }; db?: { url?: string } }
DeepPartial 在 form 初始化、配置合并等场景特别有用。注意这里对数组做了单独处理,避免把数组 map 成一个对象类型。
NonNullableFields:去掉可空字段
type NonNullableFields<T> = {
[K in keyof T]-?: NonNullable<T[K]>
}
type Nullable = { a: string | null; b: number | undefined }
type Clean = NonNullableFields<Nullable>
// { a: string; b: number }
减号 -? 去掉了可选标志,NonNullable<T[K]> 去掉了 null 和 undefined。
mapped types 的几个陷阱
1. keyof 和 as 对 symbol 键的处理
type WithSymbol = { [key: string]: number; [Symbol.iterator]: () => void }
type StringKeysOnly<T> = {
[K in keyof T as K extends string ? K : never]: T[K]
}
不这么做的话,string & K 会把 symbol 键转为 never。
2. 映射类型不会保留某些类型信息
type MyPick<T, K extends keyof T> = {
[P in K]: T[P]
}
// 如果 T 有可选属性,映射后的类型不会保留"可选"属性上的 undefined 合并行为
更好的做法是用内置的 Pick<T, K>——它做了更多 edge case 处理。
3. 递归 mapped types 的类型实例化开销
每层嵌套的 DeepPartial 都会增加实例化深度。对于超过 10 层嵌套的对象,考虑限制递归深度或使用扁平化方案:
type DeepPartialSimple<T> = {
[K in keyof T]?: T[K] extends Record<string, any>
? DeepPartialSimple<T[K]>
: T[K]
}
控制递归深度的一个方式是按层级拆分:
type DeepPartial2<T> = {
[K in keyof T]?: // 只展开第一层
T[K] extends object
? // 为深层对象专门定义的类型
: T[K]
}
mapped types 的典型应用场景
| 场景 | 实现 | 备注 |
|---|---|---|
| 给所有属性加 readonly | { readonly [K in keyof T]: T[K] } | 内置 Readonly |
| 所有属性转为可选 | { [K in keyof T]?: T[K] } | 内置 Partial |
| 键名加前缀 | [K in keyof T as \prefix_${K}`]` | 需处理非 string 键 |
| 按值过滤键 | as T[K] extends V ? K : never | 可用于 PickByValue |
| 递归不可变 | 递归调用 mapped type | 注意深度限制 |
| 键名转换 | as Capitalize<string & K> | 大小写、驼峰等 |
总结
Mapped types 的核心能力有三个:遍历键、修饰属性值、重映射键。理解这三层之后,大部分内置工具类型的实现都能读懂。不要试图在一个 mapped type 里做完所有事情——分拆成多个步骤更容易协作和维护。比如先做值类型过滤,再做键重映射,最后加修饰符。每个步骤一个类型别名,组合起来反而比复杂的单步类型更清晰。


