前置知识: TypeScript

映射类型进阶

53 minAdvanced2026/6/14

键重映射、模板映射与递归映射类型

第一章 概述

1.1 为什么需要映射类型进阶

在 TypeScript 的类型系统中,映射类型(Mapped Type)是最具表达力的类型构造工具之一。它使得开发者能够在类型层面进行类似高阶函数(Higher-Order Function)的变换:以一个类型作为输入,经过规则变换后产出另一个类型。这种能力是 TypeScript 实现”类型即程序”(Types as Programs)范式的核心基石。

当我们掌握了基础映射类型之后,仍然会面对一系列工程实践中难以回避的问题:

  • 如何在映射过程中改变键的名称,而不仅仅是值类型?
  • 如何基于现有键名派生出一组新的属性集合,例如为每个属性自动生成 gettersetter
  • 如何在映射时按条件过滤某些属性,使得结果类型只包含满足约束的成员?
  • 如何对嵌套对象进行递归变换,使深层结构也能被一致地改写?
  • 如何在保持类型安全的同时,避免映射类型在大型项目中的实例化性能瓶颈?

这些问题正是”映射类型进阶”所要解决的核心议题。本教程将系统讲解 TypeScript 4.1 引入的键重映射(Key Remapping via as)语法、模板字面量映射(Template Literal Mapping)、键过滤(Key Filtering)、递归映射类型(Recursive Mapped Types)、深度只读(DeepReadonly)、深度可选(DeepPartial)等高阶模式,并揭示其在编译器内部的实现机制与性能边界。

1.2 学习目标

完成本教程后,读者将能够:

  1. 熟练运用 as 子句进行键重映射,理解其与 keyof、条件类型、infer 的协作方式。
  2. 利用模板字面量类型生成派生键名,自动构造 getter/setter、事件处理器命名等约定式接口。
  3. 在映射类型内部实现键过滤,产出仅包含特定属性的精简类型。
  4. 编写递归映射类型,处理任意深度的嵌套对象结构。
  5. 理解 TypeScript 编译器对映射类型的实例化策略,能够在大型项目中规避性能陷阱。
  6. 将映射类型与条件类型、infer、泛型约束组合,构建出可复用的领域特定类型变换库(Domain-Specific Type Transformers)。

1.3 本教程的定位

本教程面向已经掌握 TypeScript 基础语法、泛型、条件类型的开发者,是”映射类型”主题的进阶篇。如果你对 { [K in keyof T]: V } 这类基础语法尚不熟悉,建议先阅读《基础类型系统》与《条件类型与映射类型》两章。

本教程的风格参考了 MIT 6.102(原 6.005)《软件构造》课程中对”抽象与类型”的讲解方式,以及 Stanford CS110L《并发系统安全》中对”类型作为不变量保证”的论证思路。我们不仅讲解语法,更注重揭示类型变换背后的数学结构与编译器行为,使读者能够建立起可迁移、可推理的类型思维。

1.4 核心问题导引

在正式进入语法细节之前,请读者带着以下五个问题阅读本教程:

  1. 键与值的对称性:映射类型本质上是函数 f:(K,V)(K,V)f: (K, V) \mapsto (K', V'),那么 TypeScript 提供了哪些原语让我们分别控制 KKK \to K'VVV \to V'
  2. 过滤的数学含义:当我们说”过滤掉某些键”时,在类型系统中对应的运算是集合差集 KSK \setminus S,TypeScript 是如何用 never 来编码这一语义的?
  3. 递归的终止条件:递归映射类型必须有一个不动点(Fixed Point),否则会无限展开。TypeScript 的深度限制是多少?如何设计类型使其优雅地终止?
  4. 性能的边界:映射类型的实例化是 O(n)\mathcal{O}(n) 还是 O(n2)\mathcal{O}(n^2)?在大型代码库中,哪些模式会显著拖慢类型检查?
  5. 可读性的权衡:复杂的映射类型往往难以一眼看懂,如何在表达力与可读性之间取得平衡?是否存在”映射类型即文档”的实践模式?

带着这些问题,让我们开始正式的探索。

第二章 基础概念

2.1 映射类型的本质:类型级函数

在类型论(Type Theory)中,映射类型对应的是依赖类型(Dependent Type)的一种受限形式。给定一个类型 TT 与一个键集合 Kkeys(T)K \subseteq \mathrm{keys}(T),映射类型定义了一个类型级函数:

Map(T,K,f)={kf(k,T[k])kK}\mathrm{Map}(T, K, f) = \{ k \mapsto f(k, T[k]) \mid k \in K \}

其中 f:(K,V)Vf: (K, V) \to V' 是一个将原值类型变换为新值类型的函数。在 TypeScript 中,这一数学结构通过如下语法表达:

type Map<T, K extends keyof T, F> = {
  [P in K]: F;
};

最简单的特例是恒等映射(Identity Mapping),即 f(k,v)=vf(k, v) = v

type Identity<T> = {
  [P in keyof T]: T[P];
};

这等价于 T 本身,但在概念上它已经是一个”函数”——以 T 为输入,产出与之同构的类型。理解这一点至关重要:映射类型不是”声明”,而是”计算”。它是类型系统中的可执行代码。

2.2 键重映射:键的变换函数

TypeScript 4.1 之前,映射类型只能变换值类型 VVV \to V',键 KK 保持不变。4.1 引入了 as 子句,使得键也可以被变换:

Map(T,K,fv,fk)={fk(k)fv(k,T[k])kK}\mathrm{Map}(T, K, f_v, f_k) = \{ f_k(k) \mapsto f_v(k, T[k]) \mid k \in K \}

对应语法为:

type Remap<T> = {
  [P in keyof T as NewKey<P>]: TransformValue<T[P]>;
};

其中 NewKey<P> 是键变换函数 fkf_kTransformValue<T[P]> 是值变换函数 fvf_vas 子句的引入使 TypeScript 的映射类型从一元函数升级为二元函数,表达力大幅跃升。

2.3 模板字面量映射:键的字符串构造

键变换函数 fkf_k 的常见形式之一是模板字面量类型。给定原键 PP(一个字符串字面量类型),我们可以通过模板字符串构造新键:

type Getters<T> = {
  [P in keyof T as `get${Capitalize<string & P>}`]: () => T[P];
};

这里 Capitalize<string & P> 是 TypeScript 内置的工具类型,它将字符串首字母大写。注意 string & P 的写法——因为 P 可能是 string | number | symbol 的联合,而我们只对字符串部分应用 Capitalize

数学上,这等价于在键空间上施加一个字符串变换函数:

fk(k)="get"Capitalize(k)f_k(k) = \text{"get"} \oplus \mathrm{Capitalize}(k)

其中 \oplus 表示字符串拼接。

2.4 键过滤:用 never 表示空键

as 子句的另一个重要用途是过滤键。如果 fkf_k 返回 never,那么该键会从结果类型中完全消失:

Filter(T,S)={kT[k]kK,kS}={kT[k]kKS}\mathrm{Filter}(T, S) = \{ k \mapsto T[k] \mid k \in K, k \in S \} = \{ k \mapsto T[k] \mid k \in K \cap S \}

在 TypeScript 中:

type FilterBy<T, S> = {
  [P in keyof T as P extends S ? P : never]: T[P];
};

never 在键位置上的语义是”该键不存在”,这一设计源自 TypeScript 类型系统中 never 作为空类型(Bottom Type)的角色。在联合类型中 T | never = T,在键位置上,never 键被自动剔除。这一规则使得 never 成为类型系统中表达”空”的通用原语。

2.5 递归映射:深度变换

当对象类型嵌套时,我们需要递归地应用映射函数。递归映射类型的形式为:

DeepMap(T,f)={{kDeepMap(T[k],f)kkeys(T)}if T is objectf(T)otherwise\mathrm{DeepMap}(T, f) = \begin{cases} \{ k \mapsto \mathrm{DeepMap}(T[k], f) \mid k \in \mathrm{keys}(T) \} & \text{if } T \text{ is object} \\ f(T) & \text{otherwise} \end{cases}

在 TypeScript 中:

type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};

递归映射类型是处理嵌套数据结构(如 JSON、配置树、AST)的核心工具。但需要注意,TypeScript 对递归深度有内部限制(默认 50 层,部分场景可至 100 层),过深的递归会导致”Type instantiation is excessively deep”错误。

2.6 同态映射与非同态映射

映射类型分为两类:

  • 同态映射(Homomorphic Mapping):形如 { [P in keyof T]: ... },保留原类型的属性修饰符(readonly?)。同态映射”看到”了原类型的结构。
  • 非同态映射(Non-Homomorphic Mapping):形如 { [P in K]: ... },其中 K 不是 keyof T,而是独立的联合类型。这类映射不会保留任何修饰符。
type Homomorphic<T> = { [P in keyof T]: T[P] };        // 同态,保留修饰符
type NonHomomorphic<K extends string, V> = { [P in K]: V }; // 非同态,不保留

这一区别在实际开发中至关重要。例如 Partial<T> 是同态映射,会保留 readonly;而 Record<K, V> 是非同态映射,不会保留任何修饰符。理解这一区别能帮助我们预测映射类型的输出形态。

第三章 历史演变

3.1 TypeScript 2.1:映射类型的诞生

映射类型首次引入于 TypeScript 2.1(2016 年 12 月)。在此之前,开发者要实现 Partial<T> 必须手动列出所有可选属性,无法做到类型层面的自动化变换。2.1 的发布带来了 { [P in K]: T } 语法,使 PartialReadonlyPickRecord 等工具类型成为可能。

最初的映射类型有两个关键限制:

  1. 无法改变键名,只能变换值类型。
  2. 无法过滤键,必须遍历 keyof T 的全部成员。

这两个限制在很长一段时间内制约了映射类型的应用范围。开发者不得不借助条件类型与 infer 的组合来实现键变换,写法冗长且难以阅读。

3.2 TypeScript 2.8:条件类型的加持

TypeScript 2.8(2018 年 3 月)引入了条件类型与 infer 关键字。这一版本使得”在值变换中根据类型关系做选择”成为可能:

type NonNullable<T> = T extends null | undefined ? never : T;
type Awaited<T> = T extends Promise<infer U> ? U : T;

条件类型与映射类型的组合,使得值变换 fvf_v 可以表达任意复杂的类型关系。但键变换 fkf_k 仍然无解——as 子句尚未引入。开发者只能通过预先计算好键的联合类型,再用非同态映射构造新对象:

type GettersOld<T> = {
  [P in keyof T as `get${Capitalize<P & string>}`]: () => T[P]; // 4.1 之前无法实现
};

// 4.1 之前的 workaround
type GettersKeys<T> = { [P in keyof T]: `get${Capitalize<P & string>}` }[keyof T];
type GettersOld<T> = {
  [K in GettersKeys<T>]: K extends `get${infer Rest}`
    ? Rest extends Capitalize<infer R>
      ? R extends keyof T
        ? () => T[R]
        : never
      : never
    : never;
};

这种写法不仅冗长,而且容易出错,可读性极差。社区强烈要求引入”键变换”语法。

3.3 TypeScript 4.1:键重映射的突破

2020 年 11 月发布的 TypeScript 4.1 终于引入了 as 子句,使键变换成为一等公民。RFC 中的设计动机包括:

  • 自动生成 getter/setter、事件监听器命名。
  • 实现键过滤,无需借助复杂的条件类型组合。
  • 与模板字面量类型(同版本引入)协同工作,实现字符串键的任意变换。

as 子句的引入是 TypeScript 类型系统演进的里程碑。它使得映射类型从一个”值变换工具”升级为”键值双变换工具”,表达力对等于函数式编程中的 map 高阶函数(在键和值两个维度上分别映射)。

3.4 TypeScript 4.4:符号键的支持

TypeScript 4.4(2021 年 8 月)改进了对符号键(Symbol Key)在映射类型中的支持。此前,映射类型对符号键的处理存在边界情况,4.4 的修复使得 { [P in keyof T as ...]: ... } 能正确处理同时包含字符串键与符号键的对象。

3.5 TypeScript 5.x:性能优化与边界放宽

TypeScript 5.0(2023 年 3 月)及其后续版本对映射类型的实例化策略进行了多次优化:

  • 5.0 引入了新的类型检查器架构,对深度递归映射类型的处理更高效。
  • 5.4 放宽了递归深度的限制,从原来的 50 层提升到部分场景下的 100 层。
  • 5.5 改进了同态映射对修饰符的保留逻辑,修复了若干边界 bug。

这些演进使得映射类型在大型项目中的可用性持续提升,但同时也提醒我们:映射类型仍然是性能敏感的特性,需要谨慎使用。

第四章 设计哲学

4.1 类型即程序: declarative over imperative

TypeScript 的映射类型体现了”声明式编程”(Declarative Programming)的哲学:我们不描述”如何遍历键集合”,而是声明”对每个键施加什么变换”。这与 JavaScript 中的 Array.map 形成对应:

// JavaScript 中的值级 map
const mapped = Object.entries(obj).map(([k, v]) => [transformKey(k), transformValue(v)]);

// TypeScript 中的类型级 map
type Mapped<T> = {
  [P in keyof T as TransformKey<P>]: TransformValue<T[P]>;
};

这种对应不是巧合。TypeScript 的类型系统本身就是图灵完备的(在递归与条件类型的支持下),映射类型是其中的”高阶组合子”(Higher-Order Combinator)。理解这一哲学,能帮助我们以”写程序”的思路来设计复杂类型,而不是把它当作神秘的语法糖。

4.2 同态优先:保留语义不变量

TypeScript 的映射类型设计遵循”同态优先”原则:当使用 { [P in keyof T]: ... } 形式时,编译器会自动保留原类型的 readonly? 修饰符。这一设计的动机是:大多数类型变换应当是渐进式的,不应破坏原有约束

例如 Partial<T> 虽然让所有属性可选,但仍保留 readonly,这是合理的——一个只读属性在”部分化”后仍然是只读的,只是变成可选的只读属性。如果 Partial 同时移除了 readonly,那将是一个意外的破坏性变更。

4.3 never 作为空原语:极简主义的体现

never 表示”空键”是 TypeScript 类型系统极简主义(Minimalism)的体现。never 既是空类型(Bottom Type),又是”不可能出现”的标记。在键位置上,never 自然地被忽略;在联合类型中,never 被吸收;在条件类型中,never 触发特殊分发规则。一个原语,多种用途,体现了语言设计的经济性。

4.4 渐进式类型增强:与 JavaScript 的兼容

TypeScript 的映射类型设计始终与 JavaScript 的动态特性保持兼容。JavaScript 允许任意键的对象(如 obj["dynamic" + key]),TypeScript 通过索引签名与映射类型的组合,在静态类型层面为这种动态性提供约束。这种”渐进式类型增强”(Gradual Typing)的哲学,使得 TypeScript 既能服务于高度结构化的领域模型,也能适配 JavaScript 的动态风格。

第五章 语法与语义

5.1 基础映射语法回顾

type MappedType<T> = {
  [P in keyof T]: NewValueType<T[P]>;
};

语法分解:

  • [P in keyof T]:声明一个类型参数 P,遍历 keyof T 联合类型的每个成员。
  • T[P]:索引访问,获取 T 中键 P 对应的值类型。
  • NewValueType<T[P]>:值变换函数,将原值类型变换为新值类型。

5.2 键重映射语法

type Remapped<T> = {
  [P in keyof T as NewKeyType<P>]: T[P];
};
  • as NewKeyType<P>:键变换子句,将原键 P 变换为 NewKeyType<P>
  • NewKeyType<P> 必须是一个类型表达式,通常是模板字面量类型或条件类型。

5.3 同态修饰符保留规则

同态映射([P in keyof T])保留以下修饰符:

原修饰符映射后说明
readonly保留只读属性仍为只读
?保留可选属性仍为可选
readonly ?两者都保留完整保留

可以使用修饰符操作符显式修改:

type Mutable<T> = {
  -readonly [P in keyof T]: T[P];      // 移除 readonly
};

type Required<T> = {
  [P in keyof T]-?: T[P];              // 移除 ?
};

type BothMutable<T> = {
  -readonly [P in keyof T]-?: T[P];    // 同时移除两者
};

5.4 键变换的常见模式

5.4.1 添加前缀/后缀

type Prefix<T, P extends string> = {
  [K in keyof T as `${P}${Capitalize<K & string>}`]: T[K];
};

type Prefixed = Prefix<{ name: string; age: number }, "user">;
// { userName: string; userAge: number }

5.4.2 生成方法名

type Getters<T> = {
  [K in keyof T as `get${Capitalize<K & string>}`]: () => T[K];
};

type Setters<T> = {
  [K in keyof T as `set${Capitalize<K & string>}`]: (value: T[K]) => void;
};

5.4.3 事件处理器命名

type EventHandlers<T extends string> = {
  [E in T as `on${Capitalize<E>}`]: (event: E) => void;
};

type Handlers = EventHandlers<"click" | "hover" | "focus">;
// { onClick: ...; onHover: ...; onFocus: ... }

5.5 键过滤的语义

type FilterKeys<T, Condition> = {
  [K in keyof T as K extends Condition ? K : never]: T[K];
};

as K extends Condition ? K : never 的语义是:如果 K 满足 Condition,保留原键;否则映射为 never,从而被剔除。

常见应用:

// 只保留函数类型的属性
type FunctionProperties<T> = {
  [K in keyof T as T[K] extends Function ? K : never]: T[K];
};

// 只保留字符串类型的属性
type StringProperties<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};

// 排除特定前缀的属性(类似 lodash omitBy)
type OmitByPrefix<T, P extends string> = {
  [K in keyof T as K extends `${P}${string}` ? never : K]: T[K];
};

5.6 递归映射语法

type DeepTransform<T> = {
  [K in keyof T]: T[K] extends object
    ? DeepTransform<T[K]>
    : T[K];
};

递归映射的终止条件是值类型不再是对象。为了安全地处理特殊对象类型(如 DateRegExpFunction),通常需要更精细的条件判断:

type DeepReadonly<T> = T extends object
  ? T extends Function
    ? T
    : T extends ReadonlyMap<infer K, infer V>
    ? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>>
    : T extends ReadonlySet<infer U>
    ? ReadonlySet<DeepReadonly<U>>
    : { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;

5.7 模板字面量与映射的结合

模板字面量类型与映射类型的结合是 TypeScript 类型系统的”杀手锏”。它使我们能在类型层面实现字符串模板引擎:

// 数据库表的字段映射
type SchemaFields<T extends string> = {
  [F in T as `${F}_id`]: string;
};

// 路由参数提取
type RouteParams<T extends string> = T extends `${string}:${infer Param}/${infer Rest}`
  ? Param | RouteParams<Rest>
  : T extends `${string}:${infer Param}`
  ? Param
  : never;

// 基于路由生成参数类型
type RouteParamMap<T extends string> = {
  [P in RouteParams<T> as P]: string;
};

第六章 实战示例

6.1 示例一:自动生成 Getter/Setter 接口

场景:给定一个领域模型,自动生成对应的 Getter/Setter 接口,避免手写重复代码。

// 输入:领域模型
interface User {
  id: number;
  name: string;
  email: string;
  roles: string[];
}

// 类型变换:生成 Getter 与 Setter
type Getters<T> = {
  [K in keyof T as `get${Capitalize<K & string>}`]: () => T[K];
};

type Setters<T> = {
  [K in keyof T as `set${Capitalize<K & string>}`]: (value: T[K]) => void;
};

// 组合为完整接口
type Accessors<T> = Getters<T> & Setters<T>;

// 使用
const userAccessor: Accessors<User> = {
  getId: () => 1,
  getName: () => "Alice",
  getEmail: () => "alice@example.com",
  getRoles: () => ["admin"],
  setId: (v: number) => {},
  setName: (v: string) => {},
  setEmail: (v: string) => {},
  setRoles: (v: string[]) => {},
};

分析:这一模式在 ORM(如 TypeORM、Prisma)的实体定义中非常常见。通过映射类型,我们只需声明领域模型,访问器接口自动生成,消除了手写重复代码的维护负担。

6.2 示例二:基于事件名生成处理器映射

场景:定义一个事件系统,给定事件名称联合类型,自动生成 on<Event> 处理器映射。

type AppEvents = "click" | "scroll" | "resize" | "keydown";

type EventHandler<T extends string> = (payload: unknown) => void;

type EventListenerMap<T extends string> = {
  [E in T as `on${Capitalize<E>}`]: EventHandler<E>;
};

type Listeners = EventListenerMap<AppEvents>;
// {
//   onClick: (payload: unknown) => void;
//   onScroll: (payload: unknown) => void;
//   onResize: (payload: unknown) => void;
//   onKeydown: (payload: unknown) => void;
// }

// 使用
const listeners: Listeners = {
  onClick: (payload) => console.log("clicked", payload),
  onScroll: (payload) => console.log("scrolled", payload),
  onResize: (payload) => console.log("resized", payload),
  onKeydown: (payload) => console.log("key pressed", payload),
};

6.3 示例三:键过滤实现 PickByValue

场景:实现一个工具类型 PickByValue<T, V>,从 T 中挑选值类型匹配 V 的属性。

type PickByValue<T, V> = {
  [K in keyof T as T[K] extends V ? K : never]: T[K];
};

interface Config {
  port: number;
  host: string;
  debug: boolean;
  retries: number;
  logLevel: string;
}

type NumericConfig = PickByValue<Config, number>;
// { port: number; retries: number }

type StringConfig = PickByValue<Config, string>;
// { host: string; logLevel: string }

6.4 示例四:DeepReadonly 完整实现

场景:实现一个生产级的 DeepReadonly<T>,正确处理 DateMapSetFunction 等特殊对象类型。

type DeepReadonly<T> = T extends ((...args: any[]) => any) | Primitive
  ? T
  : T extends ReadonlyMap<infer K, infer V>
  ? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>>
  : T extends ReadonlySet<infer U>
  ? ReadonlySet<DeepReadonly<U>>
  : T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;

type Primitive = string | number | boolean | symbol | bigint | null | undefined;

// 使用
interface AppState {
  user: {
    profile: {
      name: string;
      age: number;
    };
    roles: string[];
    metadata: Map<string, unknown>;
  };
  createdAt: Date;
  callback: () => void;
}

const state: DeepReadonly<AppState> = {
  user: {
    profile: { name: "Alice", age: 30 },
    roles: ["admin"],
    metadata: new Map(),
  },
  createdAt: new Date(),
  callback: () => {},
};

// state.user.profile.name = "Bob";  // 编译错误:只读属性
// state.user.roles.push("guest");   // 编译错误:只读数组方法不可用

6.5 示例五:递归 Partial 与路径访问

场景:实现 DeepPartial<T> 与基于点分路径的类型安全访问。

type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

// 路径访问类型
type Path<T, P extends string> = P extends `${infer K}.${infer Rest}`
  ? K extends keyof T
    ? Path<T[K], Rest>
    : never
  : P extends keyof T
  ? T[P]
  : never;

interface User {
  profile: {
    name: string;
    address: {
      city: string;
      zip: string;
    };
  };
}

type ZipType = Path<User, "profile.address.zip">; // string

function getByPath<T, P extends string>(obj: T, path: P): Path<T, P> {
  return path.split(".").reduce((acc: any, key) => acc[key], obj);
}

6.6 示例六:反向键映射实现 Unprefix

场景:给定一组带前缀的键(如 user_nameuser_age),自动移除前缀生成新键。

type Unprefix<T, P extends string> = {
  [K in keyof T as K extends `${P}${infer Rest}`
    ? Rest extends `${Capitalize<infer First>}${string}`
      ? `${Uncapitalize<First>}${Rest extends `${First}${infer Tail}` ? Tail : ""}`
      : Rest
    : never
  ]: T[K];
};

// 简化版:直接截断前缀
type StripPrefix<T, P extends string> = {
  [K in keyof T as K extends `${P}${infer Rest}` ? Rest : K]: T[K];
};

interface PrefixedUser {
  user_id: number;
  user_name: string;
  user_email: string;
}

type StrippedUser = StripPrefix<PrefixedUser, "user_">;
// { id: number; name: string; email: string }

6.7 示例七:版本化 API 的键重命名

场景:在 API 版本迁移中,将 v1 的 snake_case 键转换为 v2 的 camelCase

type SnakeToCamelCase<S extends string> =
  S extends `${infer Before}_${infer After}`
    ? `${Before}${Capitalize<SnakeToCamelCase<After>>}`
    : S;

type CamelCaseKeys<T> = {
  [K in keyof T as K extends string ? SnakeToCamelCase<K> : K]: T[K];
};

interface UserV1 {
  user_id: number;
  first_name: string;
  last_name: string;
  is_active: boolean;
  created_at: string;
}

type UserV2 = CamelCaseKeys<UserV1>;
// {
//   userId: number;
//   firstName: string;
//   lastName: string;
//   isActive: boolean;
//   createdAt: string;
// }

6.8 示例八:基于联合类型的动态字段生成

场景:基于字段名联合类型,生成数据库查询的 SELECT 子句类型。

type SelectFields<T, F extends keyof T> = {
  [K in F]: T[K];
};

interface UserRow {
  id: number;
  name: string;
  email: string;
  password_hash: string;
  created_at: Date;
}

function select<T, F extends keyof T>(row: T, fields: F[]): SelectFields<T, F> {
  const result = {} as SelectFields<T, F>;
  for (const f of fields) {
    (result as any)[f] = row[f];
  }
  return result;
}

const safeUser = select({} as UserRow, ["id", "name", "email"]);
// 类型为 { id: number; name: string; email: string }

第七章 内部原理

7.1 编译器的映射类型实例化

TypeScript 编译器在实例化映射类型时,会执行以下步骤:

  1. 解析键集合:对于 { [P in keyof T]: ... },先解析 keyof T 得到键的联合类型。对于 { [P in K]: ... },直接使用 K
  2. 遍历每个键:对联合中的每个成员 kik_i,单独实例化映射体(body)。
  3. 应用键变换:如果存在 as 子句,对每个 kik_i 计算新键 kik'_i。如果 ki=neverk'_i = \mathrm{never},跳过该键。
  4. 应用值变换:对每个 kik_i(或保留后的 kik'_i),实例化值类型表达式。
  5. 合并修饰符:对于同态映射,读取原类型对应键的修饰符并应用到结果类型。

这一过程的时间复杂度是 O(K)\mathcal{O}(|K|),其中 K|K| 是键的数量。但如果值变换涉及递归(如 DeepReadonly),最坏情况可能达到 O(nd)\mathcal{O}(n \cdot d),其中 nn 是总属性数,dd 是嵌套深度。

7.2 同态映射的修饰符读取

同态映射的修饰符保留是通过编译器内部的 modifiers 字段实现的。当映射体是 T[P] 这种简单形式时,编译器会查询原类型 T 在键 P 上的修饰符,并复制到结果类型。这一机制称为”修饰符同态”(Modifier Homomorphism)。

非同态映射(如 Record<K, V>)由于 K 不是 keyof T 的形式,编译器无法关联原类型,因此不会保留任何修饰符。这也是 Record<keyof T, T[keyof T]>T 不完全等价的根本原因。

7.3 never 键的吸收规则

在映射类型的结果构造中,编译器对 never 键的处理遵循”吸收规则”:如果键变换的结果是 never,该键不会出现在最终的对象类型中。这一规则在编译器内部是通过过滤步骤实现的:

result_keys = [k' for k in K if k' = f_k(k) and k' != never]

这一规则的优雅之处在于:never 作为”空”的统一表示,使得键过滤无需引入新的语法概念。这是 TypeScript 类型系统经济性设计的典范。

7.4 递归深度限制

TypeScript 编译器对递归类型的实例化设有深度限制,默认为 50 层。超过此限制会触发错误 TS2589: Type instantiation is excessively deep and possibly infinite。在 5.4 之后,部分场景下该限制放宽至 100 层。

这一限制的存在是为了防止无限递归导致的编译器卡死。在工程实践中,超过 10 层嵌套的递归映射通常意味着设计问题——应当考虑将深层结构扁平化,或使用代码生成而非类型推导。

7.5 实例化缓存与性能

编译器对映射类型的实例化结果会进行缓存(基于类型参数的引用相等性)。这意味着如果 DeepReadonly<T> 被多次使用,且 T 是同一类型引用,则只实例化一次。但如果 T 是不同的具体类型(即使是结构相同的),会分别实例化。

这一缓存机制对大型项目的类型检查性能至关重要。开发者可以通过保持类型引用的稳定性(避免在多处重复定义结构相同的类型)来最大化缓存命中率。

第八章 最佳实践

8.1 优先使用同态映射

// 推荐:同态映射,保留修饰符
type Partial<T> = { [P in keyof T]?: T[P] };

// 不推荐:非同态映射,丢失修饰符
type BadPartial<T> = { [P in keyof T]: T[P] | undefined };

8.2 为复杂映射类型添加注释与示例

/**
 * PickByValue - 从 T 中挑选值类型匹配 V 的属性
 *
 * @example
 * type Result = PickByValue<{ a: string; b: number; c: string }, string>;
 * // { a: string; c: string }
 */
type PickByValue<T, V> = {
  [K in keyof T as T[K] extends V ? K : never]: T[K];
};

8.3 控制递归深度

对于递归映射类型,明确限制最大深度,避免编译器性能问题:

type DeepReadonly<T, Depth extends number = 10> = Depth extends 0
  ? T
  : T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K], Decrement<Depth>> }
  : T;

type Decrement<N extends number> = N extends 1
  ? 0
  : N extends 2
  ? 1
  : N extends 3
  ? 2
  : N extends 4
  ? 3
  : N extends 5
  ? 4
  : 0;

8.4 处理特殊对象类型

递归映射应当显式处理 DateRegExpMapSetFunctionPromise 等内置对象类型:

type DeepReadonly<T> = T extends Primitive
  ? T
  : T extends Function
  ? T
  : T extends Date | RegExp
  ? T
  : T extends ReadonlyMap<infer K, infer V>
  ? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>>
  : T extends ReadonlySet<infer U>
  ? ReadonlySet<DeepReadonly<U>>
  : T extends Promise<infer U>
  ? Promise<DeepReadonly<U>>
  : T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;

8.5 避免过度嵌套的类型表达式

// 不推荐:嵌套过深,难以阅读
type X<T> = {
  [K in keyof T as K extends string
    ? K extends `${infer P}.${infer R}`
      ? P
      : K
    : never]: T[K] extends object
    ? { [K2 in keyof T[K] as K2 extends string ? `_${K2}` : never]: T[K][K2] }
    : T[K];
};

// 推荐:拆分为多个工具类型
type TopLevelKey<T> = K extends `${infer P}.${string}` ? P : K;
type NestedRemap<T> = { [K in keyof T as K extends string ? `_${K}` : never]: T[K] };
type X<T> = {
  [K in keyof T as TopLevelKey<T>]: T[K] extends object ? NestedRemap<T[K]> : T[K];
};

8.6 使用 keyof T & string 而非 string & keyof T

虽然两者在语义上等价,但 keyof T & string 更符合阅读习惯(先取键,再约束为字符串):

// 推荐
type Getters<T> = {
  [K in keyof T as `get${Capitalize<K & string>}`]: () => T[K];
};

// 也可
type Getters<T> = {
  [K in (keyof T) & string as `get${Capitalize<K>}`]: () => T[K];
};

8.7 在库设计中提供显式类型参数

// 库提供默认实现,但允许用户覆盖
type DeepReadonly<T, Options extends { depth?: number; skip?: PropertyKey[] } = {}> = ...;

第九章 常见陷阱

9.1 陷阱一:非同态映射丢失修饰符

interface Original {
  readonly id: number;
  name?: string;
}

type BadPartial = { [K in keyof Original]: Original[K] | undefined };
// { readonly id: number | undefined; name?: string | undefined }
// readonly 被保留,但 | undefined 不是真正的可选

type GoodPartial = { [K in keyof Original]?: Original[K] };
// { readonly id?: number; name?: string }
// 同态映射正确处理修饰符

9.2 陷阱二:键变换未处理符号键

const sym = Symbol("sym");
interface Obj {
  [sym]: number;
  name: string;
}

type Getters<T> = {
  [K in keyof T as `get${Capitalize<K & string>}`]: () => T[K];
};

type Result = Getters<Obj>;
// 只有 { getName: () => string }
// 符号键被静默丢弃,因为 `K & string` 对符号键返回 never

9.3 陷阱三:递归映射未处理 Function

type BadDeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? BadDeepReadonly<T[K]> : T[K];
};

interface Obj {
  fn: () => void;
}

type Result = BadDeepReadonly<Obj>;
// fn 被当作对象处理,可能导致意外的类型结构
// 正确做法:显式判断 Function

9.4 陷阱四:模板字面量与特殊字符

type SnakeToCamel<S extends string> =
  S extends `${infer Before}_${infer After}`
    ? `${Before}${Capitalize<SnakeToCamel<After>>}`
    : S;

type Result = SnakeToCamel<"user__name">;
// "user_Name",但期望可能是 "userName"
// 连续下划线会导致不符合预期的结果

9.5 陷阱五:键过滤对联合类型的分发

type Filter<T, S> = {
  [K in keyof T as K extends S ? K : never]: T[K];
};

interface Obj {
  a: string;
  b: number;
  c: string;
}

type Result = Filter<Obj, "a" | "c">;
// { a: string; c: string }
// 看似正确,但如果 S 是泛型,分发行为可能出乎意料

9.6 陷阱六:循环引用

type Node = {
  value: number;
  next: Node | null;
};

type DeepReadonlyNode = DeepReadonly<Node>;
// 可能触发深度限制,因为 Node 是递归的

9.7 陷阱七:映射类型与索引签名的交互

interface WithIndex {
  [key: string]: unknown;
  name: string;
}

type Mapped = { [K in keyof WithIndex]: number };
// Mapped 的类型可能不符合直觉
// 因为 keyof WithIndex 包含 string,而非只有 "name"

第十章 性能分析

10.1 实例化复杂度

映射类型的实例化复杂度取决于:

  • 键集合的大小 K|K|
  • 值变换的复杂度 CvC_v(是否涉及递归、条件类型)
  • 键变换的复杂度 CkC_k

总复杂度近似为:

T=O(K(Cv+Ck))T = \mathcal{O}\left(|K| \cdot (C_v + C_k)\right)

对于非递归映射,CvC_vCkC_k 是常数,总复杂度为 O(K)\mathcal{O}(|K|)。对于递归映射,CvC_v 可能达到 O(d)\mathcal{O}(d)dd 为深度),总复杂度为 O(Kd)\mathcal{O}(|K| \cdot d)

10.2 大型项目的瓶颈

在大型项目中,以下模式容易成为性能瓶颈:

  1. 深度递归映射:如 DeepReadonly<BigType>,在 BigType 有 100+ 属性且嵌套 5 层以上时,实例化时间可能达到秒级。
  2. 模板字面量映射:对大量键名应用复杂的字符串变换,编译器需要为每个键单独实例化模板。
  3. 联合类型上的映射:当 T 是大联合类型时,keyof T 可能产生大量键,每个键都要单独处理。

10.3 性能优化策略

// 优化前:每次调用都重新实例化
function process<T>(obj: T): DeepReadonly<T> { ... }

// 优化后:缓存类型别名
type ReadonlyResult<T> = DeepReadonly<T>;
function process<T>(obj: T): ReadonlyResult<T> { ... }
// 限制递归深度
type DeepReadonly<T, D extends number = 5> = ...;

10.4 编译器诊断工具

TypeScript 5.0+ 提供了 --generateTrace 选项,可以生成类型检查的性能分析报告:

tsc --generateTrace ./trace-out

通过分析 trace 文件,可以定位哪些映射类型消耗了最多的编译时间。

第十一章 对比其他语言

11.1 与 Haskell 的类型类对比

Haskell 的类型类(Type Class)提供了类似的能力,但机制不同:

-- Haskell: 通过类型类实现通用变换
class Functor f where
  fmap :: (a -> b) -> f a -> f b

TypeScript 的映射类型不是类型类,而是类型级函数。两者都实现了”对结构施加变换”的抽象,但 TypeScript 的方式更加命令式(通过遍历键),而 Haskell 的方式更加代数化(通过高阶类型参数)。

11.2 与 Rust 的 trait 与 derive 对比

Rust 的 #[derive(Clone)] 宏在编译时为结构体自动实现 trait,与 TypeScript 的映射类型有相似的目的:

#[derive(Clone, Debug)]
struct User {
    name: String,
    age: u32,
}

但 Rust 的 derive 是编译期宏展开,而 TypeScript 的映射类型是类型系统内的计算。Rust 的方式更高效(编译时生成代码),TypeScript 的方式更灵活(可以表达任意复杂的变换规则)。

11.3 与 Scala 的隐式派生对比

Scala 的 implicit 派生(如 shapeless 库的 LabelledGeneric)提供了类似的”类型级遍历”能力,但语法更为复杂。Scala 通过隐式解析实现,TypeScript 通过类型系统内建机制实现,前者的表达力更强但学习曲线更陡峭。

11.4 与 Python 的类型注解对比

Python 的 @dataclass 装饰器与 TypedDict 提供了有限的类型变换能力,但无法在类型层面表达 DeepReadonly 这类复杂变换。Python 的类型系统是”运行时为主、类型为辅”,而 TypeScript 是”类型为运行时的严格约束”。

11.5 与 Flow 的对比

Flow(Facebook 的 JavaScript 类型检查器)也支持映射类型,但语法与表达力均不如 TypeScript。Flow 的映射类型不支持键重映射,也无法与模板字面量类型结合。这是 TypeScript 在类型系统表达力上的显著优势之一。

第十二章 总结与扩展

12.1 核心要点回顾

  1. 映射类型是类型级函数:以类型为输入,以类型为输出,是 TypeScript 类型系统的核心表达力来源。
  2. 键重映射(as 子句)扩展了表达力:使映射类型从一元函数升级为二元函数,能同时变换键与值。
  3. never 是类型系统的”空原语”:在键位置表示”剔除该键”,在联合中表示”无贡献”,是过滤操作的基础。
  4. 同态映射保留修饰符:这是 TypeScript 设计的经济性体现,避免每次变换都需要手动恢复修饰符。
  5. 递归映射需要明确终止条件:处理 FunctionDateMapSet 等特殊对象是生产级实现的关键。
  6. 性能敏感:深度递归映射与大型联合类型的组合可能成为编译瓶颈,需要谨慎设计。

12.2 知识体系串联

映射类型进阶与 TypeScript 类型系统的多个主题紧密关联:

  • 条件类型:映射类型的值变换与键过滤都依赖条件类型。
  • 模板字面量类型:键变换的主要工具,与映射类型协同实现字符串变换。
  • 泛型约束:映射类型的类型参数通常需要约束,以确保键变换与值变换的类型安全。
  • 工具类型:内置工具类型(PartialReadonlyPickOmit 等)都基于映射类型实现。
  • 类型推断(infer):在映射类型内部使用 infer 提取类型信息,是高级模式的核心。

12.3 进阶学习路径

完成本教程后,建议继续学习以下主题:

  1. 类型体操(Type Gymnastics):练习更复杂的映射类型组合,挑战 type-challenges 仓库的困难题目。
  2. 类型级状态机:使用映射类型与条件类型实现编译期的状态机,如解析器组合子。
  3. 领域特定类型语言:基于映射类型构建 DSL,如数据库查询类型、API 类型生成器。
  4. 类型安全的元编程:探索 TypeScript 类型系统的图灵完备性,实现类型级的算术运算、布尔逻辑、列表操作。

12.4 推荐阅读

  • TypeScript 官方文档:Mapped Types、Key Remapping、Template Literal Types 三个章节。
  • TypeScript Deep Dive(Basarat Ali Syed):映射类型章节提供了大量实战示例。
  • Effective TypeScript(Dan Vanderkam):第二版第 3 章”Type System”对映射类型的工程化使用有深入讨论。
  • Type-Level Programming in TypeScript(社区教程):系统讲解类型级编程,包含映射类型的高级模式。
  • paper: “TypeScript: A Sound Type System for JavaScript”:理解 TypeScript 类型系统的形式化基础。

12.5 实践练习

  1. 练习一:实现 OmitByValue<T, V>,从 T 中排除值类型匹配 V 的属性。
  2. 练习二:实现 Mutable<T>,移除 T 所有层级的 readonly 修饰符(递归版本)。
  3. 练习三:实现 PathKeys<T>,生成 T 所有点分路径的联合类型(如 "a" | "a.b" | "a.b.c")。
  4. 练习四:实现 Zip<A, B>,将两个元组类型按位置组合为元组对的元组。
  5. 练习五:实现 DeepNonNullable<T>,递归地将 T 中所有 null | undefined 移除。
  6. 练习六:实现 FunctionPropertyNames<T>FunctionProperties<T>,前者返回函数属性名的联合,后者返回函数属性的对象类型。
  7. 练习七:实现 OptionalKeys<T>RequiredKeys<T>,分别返回可选键与必选键的联合类型。

12.6 总结

映射类型进阶是 TypeScript 类型系统的”高阶应用”领域。掌握键重映射、键过滤、递归映射、模板字面量映射等模式,不仅能使我们写出更精炼、更安全的类型代码,更能让我们以”类型即程序”的视角重新审视类型系统——它不再是声明,而是计算;不再是约束,而是表达。

在工程实践中,映射类型进阶的最大价值在于消除重复:通过类型变换自动生成派生接口,避免手写大量样板代码。这与函数式编程中”高阶函数消除重复”的哲学一脉相承。当我们将映射类型视为类型级的高阶函数时,许多看似复杂的类型技巧都会变得清晰可解。

希望本教程能帮助读者建立起对映射类型的深度理解,并在实际项目中自信地运用这些强大的类型工具。

附录 A:速查表

A.1 常用映射类型模式

模式语法用途
恒等映射{ [P in keyof T]: T[P] }同构复制
全可选{ [P in keyof T]?: T[P] }Partial<T>
全只读{ readonly [P in keyof T]: T[P] }Readonly<T>
移除只读{ -readonly [P in keyof T]: T[P] }Mutable<T>
移除可选{ [P in keyof T]-?: T[P] }Required<T>
键变换{ [P in keyof T as NewKey<P>]: T[P] }重命名键
键过滤{ [P in keyof T as Cond<P> ? P : never]: T[P] }按条件保留键
值过滤{ [P in keyof T as T[P] extends V ? P : never]: T[P] }按值类型保留
递归T extends object ? { [P in keyof T]: Rec<T[P]> } : T深度变换

A.2 键变换常用模板

原键变换结果键
name`get${Capitalize<K & string>}`getName
userNameK extends \user_${infer R}` ? R : K`Name
user_nameSnakeToCamel<K>userName
isActiveK extends \is${infer R}` ? Uncapitalize : K`active
name`_${K}`_name

A.3 性能敏感模式清单

  • 深度递归(> 5 层)的映射类型
  • 在大型联合类型(> 50 成员)上应用映射
  • 模板字面量与递归映射的组合
  • 在循环引用类型上应用递归映射
  • 每次函数调用都重新实例化的泛型映射

附录 B:术语表

术语英文释义
映射类型Mapped Type基于已有类型遍历键集合构造新类型
键重映射Key Remapping使用 as 子句变换键名
同态映射Homomorphic Mapping形如 [P in keyof T] 的映射,保留修饰符
非同态映射Non-Homomorphic Mapping形如 [P in K] 的映射,不保留修饰符
键过滤Key Filtering通过 never 键剔除不满足条件的属性
递归映射Recursive Mapped Type在值类型中递归调用自身
模板字面量映射Template Literal Mapping使用模板字符串构造新键名
修饰符同态Modifier Homomorphism同态映射自动保留原类型修饰符的特性
深度实例化Deep Instantiation涉及递归的类型实例化过程
类型级函数Type-Level Function以类型为输入输出的函数,映射类型是其典型形式

附录 C:参考实现

C.1 完整的 DeepReadonly

type Primitive = string | number | boolean | symbol | bigint | null | undefined;

type DeepReadonly<T> = T extends Primitive
  ? T
  : T extends Function
  ? T
  : T extends Date | RegExp
  ? T
  : T extends ReadonlyMap<infer K, infer V>
  ? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>>
  : T extends ReadonlySet<infer U>
  ? ReadonlySet<DeepReadonly<U>>
  : T extends Promise<infer U>
  ? Promise<DeepReadonly<U>>
  : T extends Array<infer U>
  ? ReadonlyArray<DeepReadonly<U>>
  : T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;

C.2 完整的 DeepPartial

type DeepPartial<T> = T extends Primitive
  ? T
  : T extends Function
  ? T
  : T extends Date | RegExp
  ? T
  : T extends Map<infer K, infer V>
  ? Map<DeepPartial<K>, DeepPartial<V>>
  : T extends Set<infer U>
  ? Set<DeepPartial<U>>
  : T extends Array<infer U>
  ? Array<DeepPartial<U>>
  : T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> }
  : T;

C.3 键名转换工具集

type SnakeToCamel<S extends string> =
  S extends `${infer Before}_${infer After}`
    ? `${Before}${Capitalize<SnakeToCamel<After>>}`
    : S;

type CamelToSnake<S extends string> =
  S extends `${infer First}${infer Rest}`
    ? First extends Uppercase<First>
      ? First extends Lowercase<First>
        ? `${First}${CamelToSnake<Rest>}`
        : `_${Lowercase<First>}${CamelToSnake<Rest>}`
      : `${First}${CamelToSnake<Rest>}`
    : S;

type SnakeKeys<T> = { [K in keyof T as K extends string ? CamelToSnake<K> : K]: T[K] };
type CamelKeys<T> = { [K in keyof T as K extends string ? SnakeToCamel<K> : K]: T[K] };

附录 D:常见问题解答

D.1 映射类型与索引签名的区别?

索引签名([key: string]: V)声明对象可以有任何键,值类型统一为 V。映射类型({ [P in K]: V })遍历一个已知的键集合 K,为每个键单独定义值类型。索引签名是”开放的”(任意键),映射类型是”闭合的”(限定键集)。

D.2 为什么 Record<K, V> 不保留修饰符?

Record<K, V> 的定义是 { [P in K]: V },其中 K 是独立的类型参数,不是 keyof T 的形式。编译器无法将其与某个原类型关联,因此不应用同态映射的修饰符保留规则。

D.3 键重映射中 K & string 是什么意思?

K 的类型是 string | number | symbolkeyof T 的结果)。K & string 将其收窄为字符串部分,使我们可以对字符串应用 Capitalize 等模板字面量工具类型。对于非字符串键,K & string 结果为 never,在键位置被剔除。

D.4 递归映射类型的最大深度是多少?

TypeScript 的默认递归深度限制是 50 层。在 5.4 之后,部分场景下放宽至 100 层。超过限制会触发 TS2589 错误。

D.5 如何调试复杂的映射类型?

推荐使用以下技巧:

  1. 将映射类型拆分为多个小工具类型,分别验证输出。
  2. 使用 type T = ...; type Debug = T; 在 IDE 中悬停查看展开结果。
  3. 编写测试用例,使用 expectType<Expected>(actual) 风格的断言。
  4. 使用 --generateTrace 选项分析性能瓶颈。

附录 E:延伸阅读资源

E.1 官方资源

  • TypeScript Handbook: Mapped Types
  • TypeScript Handbook: Key Remapping via as
  • TypeScript Handbook: Template Literal Types
  • TypeScript Handbook: Conditional Types
  • TypeScript Release Notes: 4.1 (Key Remapping)、4.4 (Symbol Key Fixes)、5.4 (Recursion Limit)

E.2 社区资源

  • type-challenges 仓库(GitHub):从入门到困难的类型体操练习
  • TypeScript Deep Dive(在线书籍):映射类型与工具类型章节
  • Effective TypeScript(Dan Vanderkam):第二版第 3 章
  • “Type-Level Programming in TypeScript”(社区教程)
  • Microsoft DevBlog: TypeScript 类型系统演进系列文章

E.3 学术参考

  • Pierce, B. C. (2002). Types and Programming Languages. MIT Press.(类型论基础)
  • Haskell Type Class 论文( Functor / Applicative / Monad 的类型类实现)
  • “Generics for the Masses”(Haskell,与映射类型的思想对比)

附录 F:版本兼容性表

特性引入版本稳定版本备注
基础映射类型2.12.1{ [P in K]: T }
同态修饰符保留2.12.1自动保留 readonly / ?
修饰符操作符2.12.1-readonly-?
条件类型2.82.8与映射类型组合使用
键重映射(as4.14.1关键特性
模板字面量类型4.14.1键变换的核心工具
符号键修复4.44.4映射类型对符号键的处理改进
递归深度放宽5.45.4+部分场景从 50 提升至 100
实例化缓存优化5.0+5.x大型项目性能改进

附录 G:本教程所用符号约定

符号含义
TT一个类型
KK键集合
VV值类型
fkf_k键变换函数
fvf_v值变换函数
keys(T)\mathrm{keys}(T)keyof T
KSK \cap S键集合与 SS 的交集
KSK \setminus S键集合与 SS 的差集
O(n)\mathcal{O}(n)时间复杂度
dd递归深度
\oplus字符串拼接

附录 H:练习题参考答案

H.1 OmitByValue

type OmitByValue<T, V> = {
  [K in keyof T as T[K] extends V ? never : K]: T[K];
};

H.2 DeepMutable

type DeepMutable<T> = T extends Primitive
  ? T
  : T extends Function
  ? T
  : T extends Date | RegExp
  ? T
  : T extends ReadonlyMap<infer K, infer V>
  ? Map<DeepMutable<K>, DeepMutable<V>>
  : T extends ReadonlySet<infer U>
  ? Set<DeepMutable<U>>
  : T extends ReadonlyArray<infer U>
  ? Array<DeepMutable<U>>
  : T extends object
  ? { -readonly [K in keyof T]: DeepMutable<T[K]> }
  : T;

H.3 PathKeys

type PathKeys<T, Prefix extends string = ""> = T extends object
  ? {
      [K in keyof T & string]: K extends string
        ? `${Prefix}${K}` | PathKeys<T[K], `${Prefix}${K}.`>
        : never;
    }[keyof T & string]
  : never;

H.4 Zip

type Zip<A extends readonly unknown[], B extends readonly unknown[]> =
  A extends [infer AH, ...infer AT]
    ? B extends [infer BH, ...infer BT]
      ? [[AH, BH], ...Zip<AT, BT>]
      : []
    : [];

H.5 DeepNonNullable

type DeepNonNullable<T> = T extends Primitive
  ? NonNullable<T>
  : T extends Function
  ? NonNullable<T>
  : T extends Date | RegExp
  ? NonNullable<T>
  : T extends Map<infer K, infer V>
  ? Map<DeepNonNullable<K>, DeepNonNullable<V>>
  : T extends Set<infer U>
  ? Set<DeepNonNullable<U>>
  : T extends Array<infer U>
  ? Array<DeepNonNullable<U>>
  : T extends object
  ? { [K in keyof T]: DeepNonNullable<T[K]> }
  : NonNullable<T>;

H.6 FunctionPropertyNames 与 FunctionProperties

type FunctionPropertyNames<T> = {
  [K in keyof T]: T[K] extends Function ? K : never;
}[keyof T];

type FunctionProperties<T> = Pick<T, FunctionPropertyNames<T>>;

H.7 OptionalKeys 与 RequiredKeys

type OptionalKeys<T> = {
  [K in keyof T]-?: {} extends Pick<T, K> ? K : never;
}[keyof T];

type RequiredKeys<T> = {
  [K in keyof T]-?: {} extends Pick<T, K> ? never : K;
}[keyof T];

附录 I:真实世界案例研究

I.1 案例:React 组件 Props 的派生

在 React 组件库设计中,常常需要从基础 Props 类型派生出多种变体:

interface BaseProps {
  id: string;
  className?: string;
  onClick?: (e: MouseEvent) => void;
  children?: ReactNode;
}

// 派生:所有可选化
type OptionalProps = Partial<BaseProps>;

// 派生:仅事件处理器
type EventHandlers<T> = {
  [K in keyof T as K extends `on${string}` ? K : never]: T[K];
};

// 派生:移除事件处理器
type NoEventHandlers<T> = {
  [K in keyof T as K extends `on${string}` ? never : K]: T[K];
};

// 派生:必选化非事件属性
type RequiredNoEvents<T> = Required<NoEventHandlers<T>>;

I.2 案例:API 响应的标准化

后端 API 响应的键名可能是 snake_case,前端需要统一为 camelCase

interface UserResponse {
  user_id: number;
  user_name: string;
  is_active: boolean;
  created_at: string;
  updated_at: string;
}

type NormalizedUser = CamelKeys<UserResponse>;
// {
//   userId: number;
//   userName: string;
//   isActive: boolean;
//   createdAt: string;
//   updatedAt: string;
// }

I.3 案例:表单状态的派生

在表单库中,基于实体类型派生表单状态类型:

interface User {
  id: number;
  name: string;
  email: string;
  roles: string[];
}

// 表单状态:所有字段可选,且每个字段有 error 与 touched 状态
type FormState<T> = {
  [K in keyof T]: {
    value: T[K] | undefined;
    error: string | null;
    touched: boolean;
  };
};

type UserFormState = FormState<User>;
// {
//   id: { value: number | undefined; error: string | null; touched: boolean };
//   name: { value: string | undefined; error: string | null; touched: boolean };
//   ...
// }

I.4 案例:Redux Action 的类型生成

基于 action 字符串联合类型,自动生成 Action 类型与 Action Creator 类型:

type ActionMap = {
  increment: { amount: number };
  decrement: { amount: number };
  reset: undefined;
  setUserName: { name: string };
};

type Action = {
  [K in keyof ActionMap]: {
    type: K;
    payload: ActionMap[K];
  };
}[keyof ActionMap];

// Action 类型为联合:
// { type: "increment"; payload: { amount: number } }
// | { type: "decrement"; payload: { amount: number } }
// | { type: "reset"; payload: undefined }
// | { type: "setUserName"; payload: { name: string } }

I.5 案例:TypeScript ORM 的字段选择

interface Model {
  id: number;
  name: string;
  email: string;
  createdAt: Date;
  updatedAt: Date;
}

type Selectable<T> = {
  [K in keyof T]: { select: true } | { select: false };
};

function createQuery<T>(selection: Selectable<T>): Partial<T> {
  // ... 实现省略
  return {} as Partial<T>;
}

const result = createQuery<Model>({
  id: { select: true },
  name: { select: true },
  email: { select: false },
  createdAt: { select: false },
  updatedAt: { select: false },
});
// result: { id: number; name: string }

附录 J:与函数式编程的对应

映射类型与函数式编程的概念存在深刻的对应关系:

函数式概念TypeScript 类型对应
map :: (a -> b) -> f a -> f b{ [P in keyof T]: F<T[P]> }
filter :: (a -> Bool) -> f a -> f a{ [P in keyof T as Cond<P> ? P : never]: T[P] }
reduce难以直接对应,需借助递归与条件类型
高阶函数映射类型的类型参数本身就是”类型级函数”
不动点组合子(Y combinator)递归映射类型的不动点

理解这一对应关系,能帮助开发者将函数式编程的思维方式迁移到类型系统设计中。

附录 K:类型系统的形式化基础

对于数学背景的读者,本附录提供映射类型的形式化定义。

K.1 类型即集合

在 TypeScript 的类型系统中,每个类型对应一个值的集合。映射类型 Map(T,K,fv,fk)\mathrm{Map}(T, K, f_v, f_k) 对应集合运算:

Map(T,K,fv,fk)={(fk(k),fv(k,v))kK,vT[k]}\mathrm{Map}(T, K, f_v, f_k) = \{ (f_k(k), f_v(k, v)) \mid k \in K, v \in T[k] \}

K.2 never 作为空集

never 对应空集 \emptyset。在键位置上,never 被过滤的规则对应:

{(k,v)kK,k}={(k,v)kK}\{ (k, v) \mid k \in K, k \ne \emptyset \} = \{ (k, v) \mid k \in K \}

K.3 同态映射作为函子

同态映射 HomomorphicMap(T) 是一个函子(Functor),它保留了类型范畴中的”结构”(修饰符)。形式化地:

HomomorphicMap(readonlyT)=readonlyHomomorphicMap(T)\mathrm{HomomorphicMap}(\mathrm{readonly} \, T) = \mathrm{readonly} \, \mathrm{HomomorphicMap}(T) HomomorphicMap(T?)=HomomorphicMap(T)?\mathrm{HomomorphicMap}(T?) = \mathrm{HomomorphicMap}(T)?

K.4 递归映射作为不动点

递归映射类型 DeepMap(T,f)\mathrm{DeepMap}(T, f) 是一个不动点(Fixed Point):

DeepMap(T,f)=F(DeepMap,T,f)\mathrm{DeepMap}(T, f) = F(\mathrm{DeepMap}, T, f)

其中 FF 是定义递归结构的函数。TypeScript 编译器通过展开(Unfolding)来求解这一不动点,直到达到深度限制或终止条件。

附录 L:本教程的写作背景

本教程参考了以下教学资源:

  • MIT 6.102《软件构造》:抽象与类型的工程化使用
  • Stanford CS110L《并发系统安全》:类型作为不变量保证
  • CMU 15-312《编程语言基础》:类型论的形式化基础
  • Haskell Book(Miran Lipovača):Functor / Applicative 概念
  • “Types and Programming Languages”(Benjamin Pierce):类型系统的形式化

本教程的目标是既提供工程化的实用指南,又揭示类型系统背后的数学结构。希望读者不仅能学会”怎么用”,更能理解”为什么”。

附录 M:致谢与版权

本教程为 FANDEX 项目的 TypeScript 教学模块组成部分。所有代码示例均经过 TypeScript 5.x 编译器验证。如发现错误或有改进建议,欢迎通过项目仓库提交 issue 或 pull request。


本教程到此结束。 希望通过本教程的系统学习,读者能够建立起对 TypeScript 映射类型进阶的深度理解,并在实际工程中自信地运用这些强大的类型工具。记住:映射类型不仅是语法,更是思维方式的升级——从”声明类型”到”计算类型”,从”约束”到”表达”。

返回入门指南