本节目标:读完这一节,你能读懂
type Partial<T> = { [K in keyof T]?: T[K] }这行「天书」并自己写出等价实现;能用-readonly、-?增删修饰符;能用as子句把name改写成getName、把id过滤掉;并且能解释Type 'K' is not assignable to type 'string | number | symbol'这类报错的成因与修法。
9.2 映射类型与键重映射(as)
上一节我们学会了「读取」类型:keyof 拿到键,T[K] 拿到值。这一节我们学习「生成」类型——遍历一个类型的所有键,为每个键生产出一个新属性。
如果说泛型是类型系统里的「函数参数」,那么映射类型就是类型系统里的「循环」。它是 Partial、Readonly、Pick、Record 这些内置工具类型的共同底层机制,也是类型库作者最常用的武器。
从重复的手写工具类型说起
假设项目里需要一个「所有字段都变成可选」的类型:
interface User {
id: number;
name: string;
email: string;
}
// ❌ 手写:字段一多就崩溃,而且无法复用
interface PartialUser {
id?: number;
name?: string;
email?: string;
}
换成映射类型,一行搞定,而且对任意类型都适用:
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
type PartialUser = MyPartial<User>;
// { id?: number; name?: string; email?: string }
这正是内置 Partial<T> 的源码(标准库 lib.es5.d.ts 里就是这么写的)。
映射类型的基本语法
映射类型的骨架只有三部分:
type Result<T> = {
[K in Keys]: ValueType;
};
// ↑ K 是循环变量,Keys 是要遍历的联合类型,ValueType 是每个键对应的值类型
| 位置 | 含义 | 常见取值 |
|---|---|---|
K | 循环变量,逐个取 Keys 中的成员 | 任意标识符 |
Keys | 要遍历的键集合 | keyof T、"a" | "b"、keyof T & string |
ValueType | 键 K 对应的值类型 | T[K]、string、T[K][] |
遍历的目标不一定是 keyof T,也可以是一个手写的联合:
type Flags = {
[K in "read" | "write" | "execute"]: boolean;
};
// { read: boolean; write: boolean; execute: boolean }
甚至可以把联合里的字面量作为值类型的一部分:
type Permissions = {
[K in "read" | "write"]: { granted: boolean; level: K };
};
// { read: { granted: boolean; level: "read" }; write: { granted: boolean; level: "write" } }
同态映射与非同态映射
这两者的区别是理解映射类型行为的关键,也是很多「为什么修饰符丢了」问题的答案。
同态映射(homomorphic)指形式为 [K in keyof T] 的映射。它的特点是会保留原类型的修饰符:
interface Todo {
readonly title: string;
done?: boolean;
}
// 只换值类型,不写任何修饰符
type Copy<T> = { [K in keyof T]: T[K] };
type CopiedTodo = Copy<Todo>;
// {
// readonly title: string; ← readonly 被保留
// done?: boolean; ← 可选被保留
// }
非同态映射(如 [K in "a" | "b"])没有原类型可参照,自然也就谈不上保留修饰符。
记住这条经验:只要你写的是 [K in keyof T],修饰符默认继承;想改就得显式加减。
修饰符:readonly 与 ? 的增删
在修饰符前加 + 表示添加,加 - 表示移除;不写时,同态映射默认原样保留。
// 添加修饰符(+ 可省略)
type MyReadonly<T> = { readonly [K in keyof T]: T[K] };
type MyPartial<T> = { [K in keyof T]?: T[K] };
// 移除修饰符(- 不可省略)
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type MyRequired<T> = { [K in keyof T]-?: T[K] };
用 Mutable 把只读类型解锁:
interface FrozenConfig {
readonly host: string;
readonly port: number;
}
type Config = Mutable<FrozenConfig>;
// { host: string; port: number } —— readonly 已移除
const c: Config = { host: "localhost", port: 3000 };
c.port = 8080; // ✅ 不再报 "Cannot assign to 'port' because it is a read-only property."
-? 还有一个常被忽略的副作用:它同时会移除 undefined。在 exactOptionalPropertyTypes 关闭时,{ a?: string } 的 a 读取类型是 string | undefined;Required 之后变成 string,读值不再需要判空。
| 工具类型 | 实现 | 效果 |
|---|---|---|
Partial<T> | { [K in keyof T]?: T[K] } | 全部变可选 |
Required<T> | { [K in keyof T]-?: T[K] } | 全部变必填 |
Readonly<T> | { readonly [K in keyof T]: T[K] } | 全部变只读 |
Mutable<T>(自定义) | { -readonly [K in keyof T]: T[K] } | 全部去掉只读 |
键重映射 as
从 TypeScript 4.1 起,映射类型支持在键后面接一个 as 子句,用来改写键名:
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 }
逐段拆解这个 as 子句:
`get${...}`是模板字面量类型,它在类型层面做字符串拼接(详见 10.2 模板字面量类型 )。Capitalize<K>是内置的字符串操作类型,把首字母大写。string & K是必要的收窄。因为K的类型是string | number | symbol,而Capitalize只接受string,不交叉一下就会报错:
Type 'K' is not assignable to type 'string'.
同理,如果只是加前缀而不需要首字母大写,可以写成:
type Prefixed<T> = {
[K in keyof T as `on_${string & K}`]: T[K];
};
as 子句的右侧只要是合法的「属性键类型」即可,因此可以任意组合字面量、模板字面量和条件类型。
用 as 做键过滤
as 子句右侧如果返回 never,这个键就会被直接删掉——这是映射类型最实用的技巧之一。
// 只保留值类型为 string 的字段
type StringFields<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
interface Mixed {
id: number;
name: string;
tag: string;
active: boolean;
}
type OnlyStrings = StringFields<Mixed>;
// { name: string; tag: string }
这里用到了条件类型(下一节的主角)。as 的右侧求值出 never 时,TypeScript 会丢弃该属性,于是我们获得了「按值类型筛选字段」的能力——手写 Pick<Mixed, "name" | "tag"> 也能达到同样效果,但无法应对字段增删。
按类型批量剔除也同理:
type OmitByType<T, U> = {
[K in keyof T as T[K] extends U ? never : K]: T[K];
};
type WithoutBoolean = OmitByType<Mixed, boolean>;
// { id: number; name: string; tag: string }
也可以按键名过滤,比如去掉所有以下划线开头的私有字段:
type PublicFields<T> = {
[K in keyof T as K extends `_${string}` ? never : K]: T[K];
};
映射类型对数组与元组同样有效
映射类型是「同态」地作用于元组和数组的,会保留它们的结构:
type Boxed<T> = { [K in keyof T]: { value: T[K] } };
type BoxedTuple = Boxed<[string, number]>;
// [{ value: string }, { value: number }] —— 仍然是元组,不是对象
type BoxedArray = Boxed<string[]>;
// { value: string }[]
这个特性在实现类型安全的 Promise.all 重载、函数参数映射(如 promisify)时非常有用。
常见坑与报错
坑一:as 子句漏了 & string。
Type 'K' is not assignable to type 'string | number | symbol'.
或在使用 Capitalize 时:
Type 'Capitalize<K>' does not satisfy the constraint 'string | number | symbol'.
坑二:误以为映射类型能改变运行时。 映射类型纯粹是编译期的类型运算,生成的类型没有任何运行时对象与之对应。想从类型生成运行时数据,得配合 Zod 这类库(见 13.2 Zod 模式验证与类型推导 )。
坑三:深层嵌套不会自动递归。 MyPartial<{ a: { b: string } }> 只把最外层 a 变成可选,内层 b 仍是必填。需要递归处理时,得自己写递归映射类型,这属于 10.3 递归类型与类型性能治理
的内容:
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
坑四:修饰符丢失。 一旦写成 [K in keyof T as ...] 并显式重命名了键,TypeScript 会把它视为「不再是同态映射」,原有的 readonly / ? 不会被保留,需要手动补:
type ReadonlyGetters<T> = {
readonly [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
坑五:性能。 映射类型每次都会实例化一个新类型。多层嵌套、大量键的组合会让编译变慢,类型检查器甚至可能报 Type instantiation is excessively deep and possibly infinite。关于这个错误的成因与治理手段,我们在 10.3 递归类型与类型性能治理
中会详细讨论。
一个真实工程示例:把 DTO 转成表单模型
后端返回的 UserDTO 中 id 是数字,前端表单里所有输入框的值都是字符串。映射类型可以一次性完成转换:
interface UserDTO {
id: number;
name: string;
age: number;
active: boolean;
}
// 把除布尔值以外的字段全部转成 string
type FormModel<T> = {
[K in keyof T]: T[K] extends boolean ? T[K] : string;
};
type UserForm = FormModel<UserDTO>;
// { id: string; name: string; age: string; active: boolean }
function toForm(dto: UserDTO): UserForm {
return {
id: String(dto.id),
name: dto.name,
age: String(dto.age),
active: dto.active,
};
}
后端给 UserDTO 加字段时,UserForm 和 toForm 会同时报错,逼迫我们补齐转换逻辑——这正是映射类型带来的「编译期契约」。
延伸阅读:如果想看映射类型在真实库中的更多用法,既有专题文章 /typescript-type-level-programming/ 与 /typescript-advanced-types/ 有更系统的案例。
小结
- 映射类型
{ [K in Keys]: Value }是类型层面的循环,Keys通常是keyof T,也可以手写联合。 - 同态映射
[K in keyof T]会保留原有的readonly与?;+添加、-移除修饰符。 as子句用于改写键名,右侧可以是模板字面量类型;键名运算涉及字符串操作时要用string & K收窄。as右侧求值为never时该键被删除,这是按值类型或键名模式过滤字段的标准手法。- 映射类型同样作用于元组与数组,并保留其结构;但深层递归需要自己实现,且要警惕实例化过深带来的编译性能问题。
下一节我们补齐类型运算的最后一块拼图:条件类型与 infer。有了它,上面 T[K] extends boolean ? ... : ... 这样的判断才能被完整解释,ReturnType、Awaited 这些工具类型也才不再是黑箱。
阅读导航:上一节:9.1 keyof·typeof 与索引访问类型 · 下一节:9.3 条件类型与 infer 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。