TypeScript · 映射类型与工具类型

[K in keyof T] 是类型层的 for 循环: 遍历键、改形状、换名字 — Partial/Pick/Omit 全是它的马甲

映射类型 = 遍历键 + 定制值 type MyPartial<T> = { [K in keyof T]?: T[K]; }; // 官方 Partial 的真身 keyof T = 'a' | 'b' | 'c' K in 联合 → 逐键生成一行属性 ?: 修饰符让每行变可选 T[K] 按键取原类型 a?: T['a']; b?: T['b']; c?: T['c'] 这就是"遍历+变换"的全部: 键集合、修饰符、值映射三件事 修饰符可增可减: +/- type Req<T> = { [K in keyof T]-?: T[K]; // 去掉 ? }; type RO<T> = { readonly [K in keyof T]: T[K]; // 加 readonly }; -? 去可选, +readonly 加只读 Required/Readonly 官方工具就是这两行 注意: readonly 是编译期概念, 运行时随便改 as 重映射: 改名与过滤 type Getters<T> = { [K in keyof T as `get${Capitalize<K>}`]: () => T[K]; }; name → getName() age → getAge() as never = 把这个键扔掉 Omit 的民间实现: 排除 K 匹配的键 模板字面量 + as = 键名批量变形/过滤的引擎 // 常用工具家族速览 — 全部由映射/条件类型组合而成 type P = Partial<User>; // 全可选: PATCH 场景 type V = Pick<User, 'name'>; // 挑字段 O = Omit<User, 'pwd'>; // 删字段 type M = Record<'a'|'b', number>; // 键集合 → 值形状 type R = ReturnType<typeof fn>; // 从函数反推返回类型 type D = Awaited<Promise<string>>; // 递归解 Promise → string // 组合拳: 深层 PATCH、路由参数提取、i18n 键树 — 都是上面几块积木的递归拼装 // 选型口诀: 挑字段 Pick/Omit · 变可选 Partial · 键值表 Record · 反推 typeof+ReturnType

三件事组成一切

  • • 键集合: keyof T / 联合 / 模板字面量
  • • 值映射: T[K] / 条件类型
  • • 修饰符: +/- 可选与只读
  • • Partial/Pick/Record 都是马甲

as 是变形器

  • • 模板字面量批量改名 getName
  • • as never 精准剔除键
  • • 配 Capitalize/Uppercase 组合
  • • 路由/i18n 键类型的引擎

单源驱动

  • • 一个实体类型派生全家族
  • • DTO/Patch/表单/缓存全联动
  • • 改实体一处, 派生类型自动跟进
  • • 拒绝手写第二份形状

💡 一句话理解

映射类型是类型层的 for 循环: [K in keyof T] 把 T 的每个键拎出来遍历一遍, 每个键生成一个新属性, 值你想怎么映射就怎么映射(T[K] 原样、boolean 全换成、Promise<T[K]> 全包上)。官方的 Partial/Pick/Omit/Record/Readonly 没有一个是"内置魔法", 全部是几行映射类型的公开实现。

as 重映射再给循环加一步"改名+过滤": 模板字面量把 name 拼成 getName, as never 把不要的键扔掉。掌握这三块积木 — 键集合、值映射、as 变形 — 你就能自造团队需要的任何工具类型, 而不是到处抄 200 行的 DeepXxx。

🧠 必知必会 必考 & 必会

keyof
取类型的键联合; 接口→字符串字面量联合, 数组→number|方法名(注意不是索引)。
type K = keyof { a: 1; b: 2 };   // → 'a' | 'b'
type AK = keyof number[];         // → number | 'length' | 方法名...
索引访问 T[K]
按键取类型, 键是联合时结果也是联合; 是映射类型"值部分"的原料。
type U = { a: string; b: number }['a' | 'b'];
U;   // → string | number  键联合带出值联合
typeof 值→类型
从变量/函数反推类型; 与 keyof 连用是"从常量生成联合"的惯用法。
const roles = ['admin', 'user'] as const;
type Role = (typeof roles)[number];  // → 'admin' | 'user'
映射语法
[K in 键联合]: 值; 三要素缺一不可, 键可以是任何联合(含模板字面量生成)。
type Flags = { [K in 'read' | 'write']: boolean };
Flags;   // → { read: boolean; write: boolean }
修饰符 +/-
? 与 readonly 前+-可增删; 不写 + 默认就是加, -? 才是去可选。
// 去 ?:  [K in keyof T]-?: T[K]
// 去 readonly:  -readonly [K in keyof T]: T[K]
as 重映射
K as 新键表达式; 表达式算出 never 则该键被剔除 — 过滤与改名一体。
type StripIds<T> = {
  [K in keyof T as K extends 'id' ? never : K]: T[K];
};
Partial / Required
全可选与全必填; 深层嵌套要自写 DeepPartial(递归映射)。
type DeepPartial<T> = T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
Pick / Omit
挑与删: Pick 用 keyof 约束键参数; Omit 反着来但键参数宽(易拼错, 见坑)。
type Pub = Pick<User, 'id' | 'name'>;
type Safe = Omit<User, 'password'>;
Record
键集合 → 统一值形状; 枚举/字面量联合建表首选。
type Handlers = Record<EventType, (p: never) => void>;
const h: Record<'a' | 'b', number> = { a: 1, b: 2 };
ReturnType / Parameters / Awaited
条件类型+infer 的官方成品: 从函数/Promise 反推组成类型。
type R = ReturnType<typeof loadUser>;  // → Promise<User>
type U = Awaited<R>;                    // → User
模板字面量类型
键/值的字符串变换引擎: 拼接、Uppercase、infer 拆分; 路由与 i18n 的基石。
type Route = `/${string}/edit`;
const r: Route = '/users/edit';   // ✓
const bad: Route = '/users';      // ✗ 编译错
satisfies 校验
只检查不拓宽: 字面量推断保留, 常量配置表的黄金搭档。
const table = {
  a: { v: 1 }, b: { v: 2 },
} satisfies Record<string, { v: number }>;
table.a.v;   // 保留具体结构, 不是 index 签名黑洞

🏭 生产实战 real world

场景 1 · PATCH 接口: Partial 链与深更新

"改昵称"也要传完整用户对象是老接口的痛。浅 Partial + 自定义深 Partial 分层覆盖:

type UserPatch = Partial<Pick<User, 'name' | 'avatar'>>;
type DeepPatch<T> = { [K in keyof T]?: T[K] extends object
  ? DeepPatch<T[K]> : T[K] };
const body: DeepPatch<Settings> = {
  notify: { email: false },     // 只发一层子树, 其余不动
};
await api.patch('/settings', body);

场景 2 · 事件映射: on/off 的键值强绑定

emit('pay', wrongPayload) 静默通过。键映射让每个事件名锁定自己的载荷:

type EventMap = {
  pay: { orderId: string; cents: number };
  login: { userId: string };
};
class Emitter {
  on<K extends keyof EventMap>(e: K, cb: (p: EventMap[K]) => void) {}
  emit<K extends keyof EventMap>(e: K, p: EventMap[K]) {}
}
emitter.emit('pay', { orderId: '1', cents: 100 });  // ✓

场景 3 · 路由参数提取: 模板字面量拆 :id

路径字符串里的 :id 部分被忽略, params 拼错没人管。模板字面量 + infer 从路径提取参数键:

type ParamKeys<S extends string> =
  S extends `${string:}${infer P}/`${string}` | `${string:}${infer P}`
    ? P | ParamKeys<S> : never;
type Route = '/users/:userId/orders/:orderId';
type Keys = ParamKeys<Route>;   // → 'userId' | 'orderId'
type Params = Record<Keys, string>;

场景 4 · 表单单源: 字段/校验/初值一体派生

字段名在 interface、校验器、初始值三处手写, 永远漂移。映射类型让三处同源:

type Fields = { email: string; age: number };
const initial: Fields = { email: '', age: 0 };
type Rules = { [K in keyof Fields]: (v: Fields[K]) => string | null };
const rules: Rules = {
  email: v => v.includes('@') ? null : '邮箱非法',
  age:   v => v >= 0 ? null : '年龄非法',
};
// Fields 加字段 → initial 与 rules 编译期双双报缺

场景 5 · 枚举建表: Record 保证全覆盖

switch 漏 case 静默, Record 直接强迫每个键都配值:

type Level = 'debug' | 'info' | 'error';
const color: Record<Level, string> = {
  debug: 'gray', info: 'cyan', error: 'red',
};
// 少写 'error' → 编译错, 而不是运行时 undefined

场景 6 · 序列化字段挑选: 拒绝手写 DTO

出参 DTO 与实体手工各一份。映射派生: 排除敏感字段 + 日期转字符串:

type Serialize<T> = {
  [K in keyof T as K extends 'password' | 'salt'
    ? never : K]: T[K] extends Date ? string : T[K];
};
type UserDTO = Serialize<User>;   // 自动: 无敏感字段, 日期变 string

场景 7 · 组件样式覆盖: Pick+Partial 组合

主题系统只允许覆盖指定子集且全部可选, 类型即文档:

type ThemeOverrides = Partial<Pick<Theme,
  'primary' | 'radius' | 'font'>>;
const t: ThemeOverrides = { primary: '#22d3ee' };
setTheme(t);   // 只能覆盖白名单字段, 其余编译报错

场景 8 · 中间件包装器: Parameters/ReturnType 反推

给任意 handler 加日志, 不想丢签名。类型工具反推参数与返回:

function withLog<F extends (...a: any[]) => unknown>(fn: F) {
  return (...args: Parameters<F>): ReturnType<F> => {
    logger.info('call', { name: fn.name });
    return fn(...args) as ReturnType<F>;
  };
}
const handler2 = withLog(handler);  // 签名分毫不差

场景 9 · UnionToIntersection: 联合参数收集

插件列表 (A|B|C)[] 想得到 A&B&C 的合并能力, infer 逆变位捕获:

type UnionToIntersection<U> =
  (U extends any ? (k: U) => void : never) extends
  (k: infer I) => void ? I : never;
type Caps = UnionToIntersection<CapA | CapB>;   // → CapA & CapB
function useAll(...ps: Caps[]) { ps[0].sharedMethod(); }

场景 10 · i18n 键树: 嵌套对象到点路径

t('user.profile.name') 拼错不报错。递归模板字面量生成全部合法路径:

type Paths<T, P extends string = ''> = T extends object
  ? { [K in keyof T]: Paths<T[K], P extends '' ? K & string
      : `${P}.${K & string}` }[keyof T]
  : P;
type Key = Paths<{ user: { name: string } }>;  // 'user.name'
t('user.nmae');   // ✗ 编译期抓出拼写错误

⚠️ 编码注意与常见坑 pitfalls

坑 1 · Partial 只浅一层 — 嵌套对象的内层字段仍必填. 正解: 自写 DeepPartial 递归。
// 错: Partial<Cfg> 里 cfg.db.host 仍必填
// 对: DeepPartial<Cfg> 递归映射每层
坑 2 · Omit 键参数太宽 — Omit<T, string> 直接把 T 打成 {} (键全被排除). 正解: 包一层 keyof 严格版 Omit。
type StrictOmit<T, K extends keyof T> = Omit<T, K>;
坑 3 · keyof 数组误当索引 — keyof T[] 含方法名, 不只是 number. 正解: 取元素用 T[number], 键用 keyof 只对表结构。
type E = (typeof arr)[number];   // 元素类型 ✓
坑 4 · Record 吞掉具体键 — Record<string, X> 让拼写错误全放行. 正解: 具体键联合或 satisfies。
// 错: const m: Record<string, number> = { prot: 8080 }  // typo 放行
// 对: satisfies Record<string, number> 保留字面量并给提示
坑 5 · as never 过滤忘写条件 — 重映射表达式写错全键蒸发. 正解: 渐进构建先打印结果确认。
// 调试: type _ = Show<MyMapped>;  用编辑器悬停看中间态
坑 6 · readonly 迷信 — 编译期只读, 运行时 as any 照改. 正解: readonly 防"无意", 真冻结 Object.freeze。
// 对外暴露配置: readonly 类型 + Object.freeze 双保险
坑 7 · ReturnType 遇重载 — 只取最后一个重载签名. 正解: 精确场景手写映射或改泛型函数。
// 重载函数的 ReturnType ≠ 所有分支的并
坑 8 · Exclude 按可赋值性 — 不是"相等剔除", 联合成员有交集就中招. 正解: 精确剔除用相等条件。
type Eq<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
坑 9 · Pick 不存在的键静默 — 旧版本 Pick 宽键拼接错不报错. 正解: StrictPick<T, K extends keyof T>。
Pick<User, 'emial'>;   // 可能只告警
StrictPick<User, 'emial'>;   // ✓ 编译错
坑 10 · 交叉类型属性冲突 — A & B 同名不同型属性变 never. 正解: 合并前对齐类型或用映射覆盖。
type X = { id: number } & { id: string };
X['id'];   // → never  id 永远赋不了值
坑 11 · 映射保留可选性 — 原属性可选, 映射后不带 -? 依旧可选, 判断易错. 正解: 需要统一时显式 +/-。
// 需要"全部必填的映射": [K in keyof T]-?: NewVal<T[K]>
坑 12 · 模板字面量组合爆炸 — 嵌套联合模板展开巨大, 编译慢甚至卡死. 正解: 控制联合规模, 分步工具类型。
// 错: `${A|B}${C|D}${E|F}` 三层联合直接笛卡尔积
坑 13 · 索引签名混具名键 — [k: string]: X 让所有具名键检查退化为 X. 正解: 拆成两个类型或用 satisfies。
// 错: { name: string; [k: string]: number }  // name 被同化报错
坑 14 · typeof 只对值 — 对类型再用 typeof 是错误, 类型空间没有 typeof. 正解: 类型直接引用类型名。
type T = User;          // ✓
// type T = typeof User;  ✗ User 是类型不是值
坑 15 · 工具类型嵌套过深 — 五层套娃编辑器悬停全是 type X = ..., 排查困难. 正解: 中间类型命名落盘, 别一行流。
// 对: type Step1 = ...; type Step2 = ...; 命名中间态
坑 16 · enum 当 Record 键的坑 — 数字枚举反向映射让 keyof 出怪结果. 正解: 用 as const 对象替代 enum。
const L = { debug: 'debug' } as const;
type LK = keyof typeof L;   // 'debug' 干净
坑 17 · 条件分布丢失 never — 裸 T 为 never 时不进条件体, 结果直接 never. 正解: [T] 包裹检测 never。
type IsNever<T> = [T] extends [never] ? true : false;
坑 18 · 映射生成索引签名误解 — [K in keyof T] 是具名映射, 不是 string 索引签名, Omit 后行为不同. 正解: 需要索引签名明确写 [k: string]: T。
// 具名映射保留字面量键信息, 悬停可见每键
坑 19 · 遍历顺序依赖 — 映射类型的键顺序跟原类型, 但别在注释/文档里押顺序. 正解: 顺序敏感的结构显式用元组建模。
// 键顺序 = keyof 顺序, 仅展示用, 不构成契约
坑 20 · 只会抄 DeepXxx — 直接抄 200 行工具类型, 编译慢且没人敢改. 正解: 按需实现一层, 用例锁行为, 控制递归深度。
// 自造工具三件套: keyof + in + as, 加测试型 Expect<Equal<>>