TypeScript 映射类型与模板字面量类型 — 类型的遍历与字符串运算,一次讲透
昨天的条件类型解决了"类型层面的 if/else",今天的映射类型解决"类型层面的 for 循环"——
[K in keyof T]一个语法遍历对象的所有键,批量生成新字段。再加上模板字面量类型(字符串的类型运算),你将亲手写出天天在用的 Partial、Pick、Omit——官方工具类型不再神秘,因为它们就是几行映射 + 条件类型。
目录
一、为什么需要映射类型
1.1 类型层面的"批量操作"痛点
// 值层面:遍历对象,把每个字段值翻倍 —— for in 一行搞定
for (const key in obj) { obj[key] = obj[key] * 2; }
// 类型层面的需求:把 Device 的【每个字段】都变成可选
interface Device {
id: string;
name: string;
temp: number;
status: "running" | "fault";
}
// ❌ 手写:字段多了就是体力活,而且实体加字段时容易忘同步
interface DevicePatch {
id?: string;
name?: string;
temp?: number;
status?: "running" | "fault";
}
// ❌ 还想全只读?再手写一份?
interface FrozenDevice {
readonly id: string;
readonly name: string;
// ……
}
Day 11 你学会了用官方的 Partial<T> 解决这个问题。今天的问题是:Partial 自己是怎么实现的? 答案就是映射类型。
1.2 类型层面的"派生"需求
// 工业场景:后端蛇形命名 → 前端驼峰
// 后端返回 { device_id: "CNC-001", device_temp: 65 }
// 前端类型想要 { deviceId: string; deviceTemp: number }
// 手写一遍映射?字段一多就是灾难
// 期望:一个类型工具自动"改键名" —— 这需要映射类型的 as 重映射
二、映射类型基本语法:[K in keyof T]
2.1 最小示例
// 值的世界:for (const key in obj)
// 类型的世界:[K in keyof T]
/** 把每个字段的类型都变成 string */
type Stringify<T> = {
[K in keyof T]: string;
};
interface Device {
id: string;
temp: number;
status: boolean;
}
type S = Stringify<Device>;
// {
// id: string;
// temp: string; ⭐ number 变 string
// status: string; ⭐ boolean 变 string
// }
读法:[K in keyof T] = “遍历 T 的每一个键 K”——冒号右边是"每个新字段的类型怎么算"。
2.2 同态克隆:T[K] 取原值
/** 每个字段原样复制 —— 类型克隆 */
type Clone<T> = {
[K in keyof T]: T[K];
};
type C = Clone<Device>;
// 与 Device 完全同构
// T[K]:索引访问(第 6 天学过)—— K 是"正在遍历的键",T[K] 取对应字段类型
// 相当于值层面的:
// for (const key in obj) { newObj[key] = obj[key]; }
2.3 遍历的键不必是 keyof T
// 映射类型的 [K in X] —— X 可以是任何"键的联合"
/** 遍历指定的字面量联合 */
type OnlyAxes = {
[K in "x" | "y" | "z"]: number;
};
// { x: number; y: number; z: number } ⭐ 这就是 Record 的原理!
/** 遍历另一个类型的键(键来自 T,值来自 U) */
type Relabel<T, U> = {
[K in keyof T]: U;
};
type R = Relabel<Device, boolean>;
// { id: boolean; temp: boolean; status: boolean }
三、同态与非同态:修饰符的秘密
3.1 一个隐蔽的行为差异
interface Config {
readonly url: string; // 只读字段
timeout?: number; // 可选字段
}
// 疑问:映射 Config,readonly 和 ? 还在吗?
// 实验 1:非同态映射(值的类型做了"运算")
type Mapped1 = { [K in keyof Config]: Config[K] };
// {
// url: string; ⚠️ readonly 丢了!
// timeout: number; ⚠️ ? 丢了!
// }
// 实验 2:官方 Partial / Readonly —— 修饰符保留
type P = Partial<Config>; // { readonly url?: string; timeout?: number }
// ⭐ readonly 被保留了!
3.2 同态(Homomorphic)的定义
// 同态映射 = [K in keyof T] 直接映射"同一个 T"的键
// TS 对这种写法有特殊待遇:自动保留原类型的 readonly 和 ? 修饰符
type Homo<T> = {
[K in keyof T]: T[K]; // ✅ 同态:保留修饰符
};
// 非同态 = 遍历的键不是 keyof T,或做了"包装运算"
type NonHomo = {
[K in keyof Config]: Config[K] | null; // ⚠️ 值类型变了 → 非同态
// 修饰符同样会丢!
};
// { url: string | null; timeout: number | null } readonly 和 ? 都没了
官方的 Partial/Required/Readonly 都是同态映射——所以它们保留修饰符。记住这个差异,坑点一节会再遇到它。
四、修饰符操作:+ 与 -
4.1 加可选:? (Partial 的原理)
/** 每个字段加 ? */
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
interface Device { id: string; temp: number; status: string }
type P = MyPartial<Device>;
// { id?: string; temp?: number; status?: string }
4.2 减可选:-?(Required 的原理)
/** 每个字段去掉 ? —— 减号语法是映射类型独有的 */
type MyRequired<T> = {
[K in keyof T]-?: T[K];
};
interface Options { width?: number; height?: number }
type R = MyRequired<Options>;
// { width: number; height: number } ⭐ 强制必填
4.3 加只读 / 减只读
/** 加 readonly(Readonly 的原理)*/
type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};
/** 减 readonly(官方没有,但很常用)*/
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
interface Frozen { readonly id: string; readonly temp: number }
type M = Mutable<Frozen>;
// { id: string; temp: number } ⭐ 可写了
const m: M = { id: "CNC-001", temp: 65 };
m.temp = 80; // ✅ 合法
4.4 修饰符语法总表
记忆:+ 是默认(省略不写),- 是删除——-?、-readonly 是映射类型独有的删除语法。
五、键重映射:as 子句
TS 4.1 引入的杀手级特性:映射时给键改名或过滤键。
5.1 改键名
/**
* 每个键前面加 ws_ 前缀
* 场景:WebSocket 消息字段与本地字段隔离
*/
type PrefixKeys<T> = {
[K in keyof T as `ws_${string & K}`]: T[K];
};
type Original = { temp: number; status: string };
type Prefixed = PrefixKeys<Original>;
// { ws_temp: number; ws_status: string }
// 语法拆解:
// [K in keyof T as 新键名] —— as 后面是"新键怎么算"
// string & K —— 把 K 收窄为 string(keyof 可能含 symbol)
5.2 过滤键:as never
/**
* 只保留值类型为 number 的字段
* as never 的字段直接从结果中消失!
*/
type PickByType<T, U> = {
[K in keyof T as T[K] extends U ? K : never]: T[K];
};
interface Device {
id: string;
temp: number;
battery: number;
name: string;
}
type OnlyNumbers = PickByType<Device, number>;
// { temp: number; battery: number } ⭐ id/name 被 never 干掉了
// 原理:as 后面的表达式返回 never → 该键被丢弃
// (昨天条件类型 + never 吸收的映射版应用)
5.3 组合:改名 + 过滤 + 条件
/**
* 蛇形转驼峰(工业接口最常见需求)
* 下划线后面的字母大写:device_temp → deviceTemp
*/
type SnakeToCamel<S extends string> =
S extends `${infer Head}_${infer Tail}`
? `${Head}${Capitalize<SnakeToCamel<Tail>>}`
: S;
type Camelized<T> = {
[K in keyof T as SnakeToCamel<string & K>]: T[K];
};
type Backend = { device_id: string; device_temp: number };
type Frontend = Camelized<Backend>;
// { deviceId: string; deviceTemp: number } ⭐
(SnakeToCamel 用到了模板字面量 + 递归——第七节详解,先看效果。)
六、手写工具类型全家桶(今日主菜)
昨天用了 17 个官方工具类型,今天逐个手写。每写完一个,用悬停对比官方版本——结果一致的那一刻,是 TS 学习曲线最有成就感的时刻。
6.1 结构五兄弟
// ===== 1. Partial:全可选 =====
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
// ===== 2. Required:全必填 =====
type MyRequired<T> = {
[K in keyof T]-?: T[K];
};
// ===== 3. Readonly:全只读 =====
type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};
// ===== 4. Pick:挑字段 =====
type MyPick<T, K extends keyof T> = {
[P in K]: T[P];
// ⭐ 关键:遍历的是 K(你指定的键子集),不是 keyof T!
// 值通过 T[P] 从原类型取
};
// ===== 5. Omit:删字段(= Pick + Exclude 组合)=====
type MyOmit<T, K extends keyof any> = MyPick<T, Exclude<keyof T, K>>;
// 原理拆解:
// 第一步:Exclude<keyof T, K> —— 从全部键里排除不要的
// 第二步:MyPick<T, 剩下的键> —— 挑出剩下的
验证环节(在 Playground 里做):
interface Device { id: string; name: string; temp: number; status: string }
type A = MyPartial<Device>; // 悬停:全可选 ✅
type B = MyRequired<{ a?: string }>; // 悬停:{ a: string } ✅
type C = MyReadonly<Device>; // 悬停:全只读 ✅
type D = MyPick<Device, "id" | "name">; // 悬停:{ id: string; name: string } ✅
type E = MyOmit<Device, "id">; // 悬停:{ name: string; temp: number; status: string } ✅
// 终极对比:与官方版本逐字段一致
type A2 = Partial<Device>;
// MyPartial<Device> 和 Partial<Device> 悬停显示完全一样!
6.2 联合三兄弟(纯条件类型,复习昨天)
// ===== Exclude:从联合排除 =====
type MyExclude<T, U> = T extends U ? never : T;
// ===== Extract:从联合提取(交集)=====
type MyExtract<T, U> = T extends U ? T : never;
// ===== NonNullable:去 null/undefined =====
type MyNonNullable<T> = T extends null | undefined ? never : T;
// 验证:
type Status = "running" | "standby" | "fault";
type X1 = MyExclude<Status, "fault">; // "running" | "standby"
type X2 = MyExtract<Status, "running">; // "running"
type X3 = MyNonNullable<number | null>; // number
6.3 函数反解三兄弟(纯 infer,复习昨天)
// ===== Parameters:参数元组 =====
type MyParameters<T extends (...args: any) => any> =
T extends (...args: infer P) => any ? P : never;
// ===== ReturnType:返回值 =====
type MyReturnType<T extends (...args: any) => any> =
T extends (...args: any) => infer R ? R : never;
// ===== Awaited:解 Promise(官方支持递归解多层)=====
type MyAwaited<T> = T extends Promise<infer V> ? MyAwaited<V> : T;
// 验证:
function fetchDevices(url: string, page: number): Promise<Device[]> { /* ... */ }
type P1 = MyParameters<typeof fetchDevices>; // [url: string, page: number]
type R1 = MyReturnType<typeof fetchDevices>; // Promise<Device[]>
type D1 = MyAwaited<R1>; // Device[]
6.4 Record:键值全映射
// ===== Record:遍历键联合,值统一 =====
type MyRecord<K extends keyof any, V> = {
[P in K]: V;
};
// 验证(第 5 天的状态配置表):
type DeviceStatus = "running" | "standby" | "fault" | "offline";
const STATUS_CONFIG: MyRecord<DeviceStatus, { text: string; color: string }> = {
running: { text: "运行中", color: "#00ff88" },
standby: { text: "待机", color: "#ffaa00" },
fault: { text: "故障", color: "#ff4444" },
offline: { text: "离线", color: "#7a8ba0" }
};
// ⭐ 少写一个状态 → 编译报错(完整性由类型系统保证)
6.5 手写清单打卡
13 个全部手写并验证通过 = 明天 BOSS 战战役 A 的基础盘。
七、模板字面量类型:字符串的类型运算
7.1 基本语法
// 值层面:模板字符串拼接
const event = `device:${action}`;
// 类型层面:模板字面量类型 —— 对字符串字面量类型的拼接
type Greeting = `hello ${"world" | "ts"}`;
// "hello world" | "hello ts"
// 与联合组合 —— 笛卡尔积
type DomEvent = `on${"Click" | "Change" | "Hover"}`;
// "onClick" | "onChange" | "onHover"
// 大小写组合
type Genders = `${"m" | "f"}${"ale" | "emale"}`; // 开玩笑的,但语法合法
7.2 占位符里能放什么
type A = `id-${number}`; // id-1、id-100…… 所有 "id-数字" 形式
type B = `v${string}`; // v 开头的任意字符串
type C = `${string}@${string}.com`; // 邮箱形状(宽松版)
// 场景:路由参数的类型约束
type Route = `/${string}`;
const r1: Route = "/devices"; // ✅
const r2: Route = "devices"; // ❌ 必须以 / 开头
// 场景:版本号格式
type Version = `v${number}.${number}.${number}`;
const v: Version = "v1.2.3"; // ✅
const v2: Version = "1.2.3"; // ❌ 缺 v 前缀
7.3 infer 在模板串中:捕获字符串片段
/** 提取冒号前的前缀 */
type ExtractDomain<E extends string> =
E extends `${infer D}:${string}` ? D : never;
type D1 = ExtractDomain<"device:update">; // "device"
type D2 = ExtractDomain<"hub:create">; // "hub"
type D3 = ExtractDomain<"invalid">; // never(不匹配)
/** 提取前缀和后缀两段 */
type Split<E extends string> =
E extends `${infer Head}:${infer Tail}` ? [Head, Tail] : never;
type S1 = Split<"device:update">; // ["device", "update"]
/** 字符串递归:逐字符拆开成元组(类型体操预告) */
type Chars<S extends string> =
S extends `${infer C}${infer Rest}` ? [C, ...Chars<Rest>] : [];
type C1 = Chars<"abc">; // ["a", "b", "c"]
八、内置字符串工具类型
TS 4.7 内置了四个"字符变换"工具类型(注意:只对字面量类型生效):
type A = Uppercase<"running">; // "RUNNING"
type B = Lowercase<"RUNNING">; // "running"
type C = Capitalize<"cnc">; // "Cnc"(首字母大写)
type D = Uncapitalize<"Cnc">; // "cnc"(首字母小写)
// ⚠️ 对非字面量的 string 无效:
type E = Uppercase<string>; // string(不报错,但也没有变换效果)
// 实战场景:常量命名转换
type ScreamingSnake<S extends string> = Uppercase<S>;
type EnvKey = ScreamingSnake<"hubUrl">; // "HUBURL"(注意:中间不会加下划线)
// 实战场景:Vue 风格事件名(kebab → camel)
type KebabToCamel<S extends string> =
S extends `${infer Head}-${infer Tail}`
? `${Head}${Capitalize<KebabToCamel<Tail>>}`
: S;
type K1 = KebabToCamel<"device-update">; // "deviceUpdate"
type K2 = KebabToCamel<"alert-raise-now">; // "alertRaiseNow"
九、工业实战场景全覆盖
9.1 场景一:后端蛇形 → 前端驼峰(接口层转换)
/**
* 大屏项目真实痛点:后端 Python 服务返回蛇形命名
* 手写转换函数类型不安全?用映射 + 模板串自动生成类型
*/
type SnakeToCamel<S extends string> =
S extends `${infer Head}_${infer Tail}`
? `${Head}${Capitalize<SnakeToCamel<Tail>>}`
: S;
/** 整个对象的键批量转驼峰 */
type Camelize<T> = {
[K in keyof T as SnakeToCamel<string & K>]: T[K];
};
// 后端接口文档抄来的类型(蛇形)
interface DeviceApiRaw {
device_id: string;
device_name: string;
work_temp: number;
is_online: boolean;
}
// 前端类型(驼峰)—— 一行派生
type DeviceDto = Camelize<DeviceApiRaw>;
// {
// deviceId: string;
// deviceName: string;
// workTemp: number;
// isOnline: boolean;
// }
// 配套的运行时转换函数也有了精确类型签名:
function camelizeDevice(raw: DeviceApiRaw): DeviceDto {
return {
deviceId: raw.device_id,
deviceName: raw.device_name,
workTemp: raw.work_temp,
isOnline: raw.is_online
};
}
9.2 场景二:事件名的自动生成与载荷注册表
/**
* 事件域 × 动作 = 全部合法事件名(编译期展开)
* 手写要写 9 个字符串字面量,类型生成只要 2 行
*/
type Domain = "device" | "alert" | "hub";
type Action = "update" | "remove" | "create";
type EventName = `${Domain}:${Action}`;
// "device:update" | "device:remove" | "device:create"
// | "alert:update" | "alert:remove" | "alert:create"
// | "hub:update" | "hub:remove" | "hub:create" —— 9 个
/**
* 事件名 → 载荷类型的注册表(映射 + 条件 + infer 组合)
*/
type EventPayloads = {
[E in EventName]: E extends `device:${infer A}`
? A extends "update" ? Device : { id: string }
: E extends `alert:${infer A}`
? AlertRecord
: { time: number };
};
// 2 行类型代码生成 9 个事件的精确载荷 —— 手写要写 9 个接口
// 配合 Day 9 的泛型事件总线:
const bus = new EventBus<EventPayloads>();
bus.on("device:update", d => console.log(d.temp)); // ✅ d: Device
bus.emit("alert:remove", { id: "1" }); // ✅ 载荷校验
9.3 场景三:表单状态自动生成
/**
* 表单场景:编辑设备的表单状态 = 每个字段变 "值 + 错误信息 + 是否触碰过"
* 手写三个字段还好,20 个字段的项目呢?
*/
type FormState<T> = {
[K in keyof T]: {
value: T[K]; // 当前值(类型与实体字段联动)
error: string | null; // 校验错误
touched: boolean; // 是否触碰过
};
};
interface Device {
id: string;
temp: number;
status: "running" | "fault";
}
type DeviceForm = FormState<Device>;
// {
// id: { value: string; error: string | null; touched: boolean };
// temp: { value: number; error: string | null; touched: boolean };
// status: { value: "running" | "fault"; error: string | null; touched: boolean };
// }
// 初始化函数的返回类型精确锁定:
function initForm<T>(entity: T): FormState<T> {
const result = {} as FormState<T>;
for (const key in entity) {
const k = key as keyof T;
result[k] = { value: entity[k], error: null, touched: false };
}
return result;
}
const form = initForm(device);
form.temp.value.toFixed(1); // ✅ number(类型联动保持)
form.temp.error = "温度超限"; // ✅
9.4 场景四:只读配置快照(DeepReadonly 预告)
/**
* 大屏主题配置:嵌套结构,一旦生成不允许任何修改
* 普通 Readonly 只管一层 —— 深层字段照样能改!
*/
const theme = {
colors: {
primary: "#00ff88",
danger: "#ff4444"
},
chart: {
line: { width: 2, smooth: true }
}
};
type ReadonlyTheme = Readonly<typeof theme>;
const t: ReadonlyTheme = theme;
// t.colors.primary = "#000"; // ✅ 竟然不报错!Readonly 只锁了第一层
// 深度只读需要递归映射(明天 BOSS 战手写):
type DeepReadonly<T> = T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> } // 每层递归加 readonly
: T;
type DeepTheme = DeepReadonly<typeof theme>;
const t2: DeepTheme = theme;
// t2.colors.primary = "#000"; // ❌ 编译错误!任意深度都被锁住 ✅
十、类比记忆:流水线 vs 印章
映射类型 = 工厂流水线
┌──────────────────────────────────────┐
│ 原料:T 的键一个一个上传送带 │
│ ([K in keyof T]) │
│ 每个键经过工位加工: │
│ 工位 1:值加工(T[K] → T[K] | null)│
│ 工位 2:修饰符(加 ? / 减 readonly) │
│ 工位 3:键改名(as 新名字) │
│ 质检员 as never:不合格的直接下线 │
│ 产出:一条全新的类型 │
└──────────────────────────────────────┘
模板字面量 = 印章拼字
┌──────────────────────────────────────┐
│ `${Domain}:${Action}` │
│ ↑印章有俩空位: │
│ 空位 1 印 "device"|"alert"|"hub" │
│ 空位 2 印 "update"|"remove"|... │
│ 每种组合盖出一个字符串字面量 │
│ 9 个印章 = 事件名的全集 │
└──────────────────────────────────────┘
十一、常见坑点与最佳实践
坑点 1:非同态映射丢失修饰符
interface Config { readonly url: string; timeout?: number }
// 想给每个字段加个 null —— 顺手一写:
type WithNull = { [K in keyof Config]: Config[K] | null };
// { url: string | null; timeout: number | null }
// ⚠️ readonly 和 ? 都丢了!(值类型运算 → 非同态)
// 想保修饰符 + 加工值的组合没有银弹:
// 方案:接受丢失(明确知道)或用交叉类型补回特定字段
坑点 2:Omit 的键不受约束(拼错不报错)
// Omit<T, K> 的 K extends keyof any(string | number | symbol 均可)
type A = Omit<Device, "nonexistent">;
// ✅ 不报错!拼错的键被静默忽略 —— 隐患!
// ✅ 严格版(拼错爆红):
type StrictOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
type B = StrictOmit<Device, "nonexistent">; // ❌ 编译错误 ✅
坑点 3:MyPick 的 K 约束写错
// ❌ 常见错误:遍历写成 keyof T(那就变成全量克隆了)
type WrongPick<T, K extends keyof T> = {
[P in keyof T]: T[P]; // ⚠️ 这会遍历所有键,Pick 失效!
};
// ✅ 正确:遍历 K
type RightPick<T, K extends keyof T> = {
[P in K]: T[P]; // 只遍历指定的键子集
};
坑点 4:键重映射时 keyof 含 symbol
// keyof T 的类型是 string | number | symbol
// 模板字面量只接受 string —— 直接拼会报错:
type Bad<T> = {
[K in keyof T as `prefix_${K}`]: T[K]; // ❌ K 可能是 symbol!
};
// ✅ 收窄:string & K
type Good<T> = {
[K in keyof T as `prefix_${string & K}`]: T[K]; // ✅
};
坑点 5:字符串工具对非字面量无效
type A = Uppercase<"abc">; // "ABC" ✅
type B = Uppercase<string>; // string ⚠️ 没有变换效果!
// 启示:模板串工具链的前提是"输入是字面量类型"
// 如果泛型参数被推断成宽泛的 string,整条变换链失效
// 解法:给泛型加 extends string 约束,保持字面量细腻度
坑点 6:映射类型和索引签名混淆
// 索引签名:键无限,值统一("任何 string 键都是 number")
type IndexSig = { [key: string]: number };
// 映射类型:键有限(遍历已知的键联合)
type Mapped = { [K in "a" | "b"]: number }; // { a: number; b: number }
// 区别记忆:
// [key: string]: —— 冒号前是"一个变量名",表示任意键
// [K in X]: —— 冒号前是"in 某个联合",表示枚举已知键
最佳实践清单
每手写一个工具类型,立即悬停对比官方版——不一致就排查
键重映射 + 条件类型是自定义工具类型的终极武器(过滤 + 改名一体)
递归映射要设终止条件(
T extends object ? ... : T兜底原始类型)Omit 换 StrictOmit,把拼错键的隐患消灭在编译期
模板串变换链的输入保持字面量(
extends string约束)映射类型的键名用 P 或 K(与官方源码一致:
[P in K]、[K in keyof T])复杂变换拆中间类型:
type CamelKeys = ...; type Result = ...
十二、自测挑战
Q1:[K in keyof T] 和 { [key: string]: T }(索引签名)的区别是什么?
Q2:手写 MyPartial<T> 和 MyRequired<T>——减号语法 -? 是什么含义?
Q3:MyPick<T, K extends keyof T> 为什么遍历的是 K 而不是 keyof T?写错会怎样?
Q4:MyOmit<T, K> 的实现原理是什么(两步拆解)?为什么官方 Omit 的拼错键不报错?
Q5:什么是同态映射?为什么 Partial<Config> 保留 readonly 而 { [K in keyof Config]: Config[K] | null } 不保留?
Q6:as never 在键重映射中的作用是什么?写一个 OmitByType<T, U>(剔除值类型为 U 的字段)。
Q7:写出以下类型的结果:
type Event = `${"device" | "hub"}:${"get" | "set"}`;
Q8:手写 SnakeToCamel<"work_temp_max"> 的递归执行过程(三轮各捕获什么)。
Q9:写一个 Nullable<T>——每个字段变成 T[K] | null。它会保留原字段的 readonly/? 修饰符吗?为什么?
Q10:设计 Getters<T>:键加 get 前缀并首字母大写,值变成返回该类型的函数。{ name: string } 应得 { getName: () => string }。
十三、总结与知识图谱
映射类型与模板字面量类型(类型的遍历与字符串运算)
│
├── 映射类型
│ ├── 语法:[K in keyof T](类型的 for 循环)
│ ├── 同态(保留 readonly/?) vs 非同态(丢失)
│ ├── 修饰符:?(加可选)/-?(去可选)/ readonly / -readonly
│ ├── 键重映射 as:改名(as 新键)/ 过滤(as never)
│ └── 手写全家桶:Partial/Required/Readonly/Pick/Omit/Record
│
├── 模板字面量类型
│ ├── `${A}${B}`:字面量拼接(联合 → 笛卡尔积)
│ ├── 占位符:string / number / 具体字面量联合
│ ├── infer 捕获片段:`${infer D}:${string}` → D
│ └── 递归拆串:Chars<"abc"> → ["a","b","c"]
│
├── 内置字符串工具
│ ├── Uppercase / Lowercase / Capitalize / Uncapitalize
│ └── 只对字面量生效(string 无效果)
│
├── 与前两天的组合
│ ├── 映射 + 条件:PickByType / NumericKeys(字段类型过滤)
│ ├── 映射 + infer:SnakeToCamel / KebabToCamel
│ └── 递归映射:DeepReadonly / DeepPartial(明天 BOSS 战)
│
└── 工业实战
├── Camelize<T>:蛇形转驼峰(接口层)
├── EventPayloads:事件名生成 + 载荷注册表
├── FormState<T>:表单状态批量派生
└── DeepReadonly:嵌套配置快照
一句话总结:映射类型是类型层面的 for 循环,as 重映射让它既能改键又能过滤键;模板字面量让字符串在类型层面可拼接可解析——加上昨天的条件类型与 infer,TS 类型系统的"图灵完备"四件套已全部到齐,明天 BOSS 战见。
延伸阅读
下一步
明天(Day 14)是第二周 BOSS 战:把本周所有知识(泛型、约束、条件类型、infer、映射类型、模板串)集中检阅——手写 typed-utils 工具类型库(13+ 个),并用泛型重构第 1 周的 dataHub(Repository + Result 错误处理 + 泛型事件总线)。
官方工具类型不神秘:它们就是几行映射 + 条件类型,你今天全写过了。
每天花 2 小时,28 天通关 TypeScript 深入。加油!