《TypeScript编程入门》9.2 映射类型与键重映射(as)

本节讲解映射类型:如何用 [K in keyof T] 遍历一个类型的所有键并批量生成新类型,如何用 readonly 与 ? 的加减号增删修饰符,以及 TypeScript 4.1 引入的键重映射 as 如何借助模板字面量类型改写键名、借助 never 过滤掉不需要的键。本节会手写 Partial、Readonly、Required 等工具类型的内部实现,读完你能为自己的项目定制专用工具类型。

本节目标:读完这一节,你能读懂 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 子句:

  1. `get${...}` 是模板字面量类型,它在类型层面做字符串拼接(详见 10.2 模板字面量类型 )。
  2. Capitalize<K> 是内置的字符串操作类型,把首字母大写。
  3. 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes