TypeScript 映射类型入门:让类型跟着数据结构一起变化

年前把中后台配置平台里几个重复的类型定义翻出来重整时,我干脆停下来把 PartialPickRecord 这些工具类型的实现挨个抄进一个空文件,逐行拆开看它们到底是怎么写出来的。之前一直是拿来就用,从没认真想过它们凭什么能自动跟着源类型变。

抄到一半我就明白了:这几个内置工具类型没有一个是"魔法",它们全都建立在同一个语法结构上——映射类型。把这个结构吃透,那些看着高级的工具类型立刻就变得平平无奇了。

映射类型解决的核心问题是重复。一个数据模型改了,围着它长出来的一堆相关类型也要跟着改,忘了同步,类型就和真实业务慢慢脱节。它的思路是:不手动复制一份类型,而是基于已有类型推导出新类型,源结构一变,相关类型自动跟着变。

我以前只把它当成 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 新增 emailReadonlyUser 会自动跟着变。这一点我也验了:在 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,不需要靠人记得同步 ReadonlyUserUserDraftUserValidators

内置工具类型都是映射类型

回到我最初那份"抄源码"的笔记。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 校验函数拿到的是 stringage 校验函数拿到的是 number。类型跟着字段走,减少手动维护。类似的还有表单状态:

1type FieldState<T> = {
2  [Key in keyof T]: {
3    value: T[Key];
4    touched: boolean;
5    error?: string;
6  };
7};

如果 User.agenumber,那么 FieldState<User>['age'].value 也是 number。这种类型关系能让表单封装更可靠,尤其是字段很多的中后台页面。

更新接口也适合用映射类型约束"只能提交可修改字段"。别直接 Partial<User>,它可能把 idcreatedAtrole 这类不该由前端改的字段也放进去:

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 这类结构确实经常和原始模型强相关,用 PartialPickOmitRecord 或自己包一层清楚的工具类型,比手写多份字段稳得多。字段一改,相关类型跟着走,这就是它最实在的价值。先把 keyofT[Key][Key in keyof T] 这几块吃透,再按真实重复场景去抽工具类型,通常就够用了。