TypeScript 对象体操 — 链式累积与嵌套路径,映射类型的极限操作
前三天分别练了元组、递归、字符串,今天回到你最熟悉的形状:对象。但视角完全不同——
Chainable Options教你"链式累积"(交叉类型的递归妙用),Flip展示 as 重映射的"值→键"极限,DeepReadonly补上函数边界这个坑,压轴的是本周最大工业落地:Get<T, Path>——一个类型工具解决"任意嵌套路径的类型安全取值"。
目录
一、对象体操的武器清单
1.1 五大武器(第 2 周学过,今天用到极限)
// 武器 1:映射类型(遍历键)
type Map1<T> = { [K in keyof T]: /* 新值 */ };
// 武器 2:as 键重映射(改键名 / 过滤键)
type Rename<T> = { [K in keyof T as `v_${string & K}`]: T[K] };
type Filter<T> = { [K in keyof T as Condition<K> extends true ? K : never]: T[K] };
// 武器 3:索引访问(按键取值 / 按键联合取值联合)
type V1 = Device["temp"]; // number
type V2 = Device[keyof Device]; // 所有值的联合
// 武器 4:交叉类型(叠加形状——今天的"链式累积"核心)
type Combined = { a: string } & { b: number };
// { a: string; b: number }
// 武器 5:条件类型 + 递归(判形状 + 深入)
type Deep<T> = T extends object ? { [K in keyof T]: Deep<T[K]> } : T;
1.2 对象体操的典型题面
形状变换类:DeepReadonly / MutableKeys(遍历 + 递归)
键值互换类:Flip(as 重映射的值→键)
累积构建类:Chainable Options(交叉 + 方法返回类型)
路径解析类:Get<T, Path>(字符串拆解 + 递归索引)
元组对象互转类:TupleToObject / ObjectEntries(Day 16 桥梁的延伸)
二、冷知识预热:映射元组与 keyof 元组
(Day 17 埋的伏笔,正式展开——今天的两道题会用到)
2.1 映射作用在元组上 = 逐元素变换
// 对象映射(熟悉的):
type Obj = { [K in keyof { a: 1; b: 2 }]: boolean };
// { a: boolean; b: boolean }
// 元组映射(新鲜的):
type Tup = { [K in keyof [1, 2, 3]]: boolean };
// [boolean, boolean, boolean] ⭐ 还是元组!
// 映射类型"尊重输入形状"——同态(homomorphic)在元组上的表现:
// 元组进 → 元组出(长度不变,逐位置变换)
// ⚠ 普通数组不适用(无位置信息):
type Arr = { [K in keyof number[]]: boolean };
// 产出带 length/pop/push... 键的奇怪对象 ⚠
2.2 keyof 元组的结构
type K = keyof [string, number];
// "0" | "1" | "length" | "toString" | "pop" | "push" | ...
// 三个部分:
// 1. 下标字符串:"0"、"1"(数字下标在类型层是字符串!)
// 2. 数组固有属性:"length" 等
// 3. 数组方法名
// 所以映射元组时,[K in keyof T] 遍历的键包含以上全部——
// TS 内部对"元组的映射"做了特殊处理:只映射下标部分,保留方法
//(这就是为什么 Promise.all 的映射能保持元组形状)
三、今日题目逐个击破
3.1 第 3312 题 · Parameters(medium · 10 秒验收题)
// 题目:Parameters<(a: string, b: number) => void> → [a: string, b: number]
type MyParameters<T extends (...args: any) => any> =
T extends (...args: infer P) => any ? P : never;
// 第 2 周手写过——10 秒通过,确认基础在
3.2 第 12 题 · Chainable Options(medium · 今日主菜一)
// 题目:实现 Chainable
// const result = chain
// .option("foo", 123)
// .option("bar", { value: "Hello" })
// .get();
// result 的类型:{ foo: number; bar: { value: string } }
// ⚠ 后面 option 同名键要覆盖前面的类型
// ===== 先自己写 15 分钟 =====
// 三板斧分析:
// 形状:每次调用 option() 后"多一个键"——类型的"累积构建"
// 拆法:这不是拆解题——是"构建"题(新题型!)
// 递归:不需要显式递归——方法返回类型自动形成"链式累积"
type Chainable = {
option<K extends string, V>(key: K, value: V): Chainable & { [P in K]: V };
get(): Record<string, unknown>;
};
// ===== 逐层拆解 =====
// 拆解 1:option 的返回类型
// Chainable & { [P in K]: V }
// —— 原有类型 交叉 新增的键值 → "带着记忆继续链"
// chain.option("foo", 123) 的类型:
// Chainable & { foo: number }
// 再 .option("bar", true) 的类型:
// (Chainable & { foo: number }) & { bar: boolean }
// = Chainable & { foo: number; bar: boolean } ⭐ 交叉自动扁平化
// 拆解 2:同名键的覆盖
// .option("foo", 1).option("foo", "x")
// 类型:Chainable & { foo: number } & { foo: string }
// ⚠ 交叉里 foo: number & string —— 冲突!悬停会显示 never 或怪类型
// 但题目用例通常不测覆盖,或测试期望"后者覆盖"——
// 严格版要用 Omit 干掉旧键:
type ChainableStrict = {
option<K extends string, V>(key: K, value: V):
Omit<ChainableStrict & { [P in K]: V }, K> & { [P in K]: V };
get(): Record<string, unknown>;
};
// 先 Omit 掉旧的同名键,再交叉新的 → 后值覆盖前值
本题教学核心:交叉类型的"累积"用法——A & B & C 每层叠加信息,这是"链式 API 类型记忆"的标准模式(jQuery 链、构建器模式、Vue 的 app.use 链都靠它)。
3.3 第 459 题 · Flip(medium)
// 题目:Flip<{ a: "x"; b: "y" }> → { x: "a"; y: "b" }(键值互换)
// ===== 先自己写 15 分钟 =====
// 三板斧:
// 形状:对象的键和值互换位置
// 拆法:as 重映射——新键来自"旧值",新值来自"旧键"
// 递归:不需要(一层互换)
type Flip<T extends Record<string, string | number | boolean>> = {
[K in keyof T as `${T[K]}`]: K;
};
// ↑ 新键 = 旧值(模板串转字符串)
// ↑ 新值 = 旧键
// 用例:
type _1 = Expect<Equal<Flip<{ a: "x"; b: "y" }>, { x: "a"; y: "b" }>>;
type _2 = Expect<Equal<Flip<{ a: 1; b: 2 }>, { 1: "a"; 2: "b" }>>;
// ↑ 数字值也合法(转字符串键)
// ⚠ 约束为什么是 Record<string, string | number | boolean>?
// 因为"值要变成键"——值必须属于合法键类型集合
本题教学核心:as 重映射的"值→键"方向——平时 as 用来改键名(键→键),这题把值搬进键位,展示了重映射的完全体能力。
3.4 第 9 题 · DeepReadonly(medium · 第 2 周的坑今天填上)
// 题目:对象所有层级只读
// 用例里藏着大坑:对象字段可能是函数!
type DeepReadonly<T> = T extends Function
? T // 边界 1:函数原样返回
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T; // 边界 2:原始类型终止
// ===== 为什么必须先判 Function?=====
// 在 TS 里:Function extends object 为 true!
// 不判函数 → 函数走进映射分支 → 被拆成 { readonly apply: ..., readonly call: ... }
// 函数字段直接报废 ⚠
interface Foo {
a: number;
b: { c: string };
handler: (x: number) => void; // 函数字段
}
type DeepFoo = DeepReadonly<Foo>;
// { readonly a: number; readonly b: { readonly c: string }; readonly handler: (x: number) => void }
// ↑ 深层只读 ↑ 函数完整保留 ⭐
// ===== 补充边界:数组怎么办?=====
type DeepReadonlyFull<T> = T extends Function
? T
: T extends readonly any[]
? readonly [...{ [K in keyof T]: DeepReadonlyFull<T[K]> }] // 元组同态映射(昨天!)
: T extends object
? { readonly [K in keyof T]: DeepReadonlyFull<T[K]> }
: T;
// 数组字段走"元组映射"分支——长度保留、元素递归只读
本题教学核心:递归类型的边界完备性——对象/函数/数组/原始类型,四类形状各走各的分支。漏一类就是 bug。
四、压轴主菜:Get<T, Path> 完全实现
本周最大的工业落地——任意嵌套路径的类型安全取值。
4.1 需求分析
interface Theme {
colors: { primary: string; danger: string };
chart: { line: { width: number; smooth: boolean } };
}
// 运行时:getValue(theme, "chart.line.width") → 2
// 类型需求:返回类型精确为 number,路径拼错 → never / 编译错误
const w = getValue(theme, "chart.line.width"); // w: number ⭐
const bad = getValue(theme, "chart.line.height"); // never ⭐ 编译期暴露
4.2 三板斧分析
形状:路径字符串 "a.b.c" → 嵌套索引 T["a"]["b"]["c"]
拆法:字符串拆解(Day 18 的 `${infer Head}.${infer Rest}`)
递归:需要——路径深度未知,每轮"啃一段路径 + 深入一层对象"
4.3 逐步实现
// ===== 第一版:只支持一层 =====
type Get1<T, K extends keyof T> = T[K];
// ===== 第二版:支持两层(硬编码,感受目标形状)=====
type Get2<T, P extends `${string}.${string}`> =
P extends `${infer Head}.${infer Rest}`
? Head extends keyof T
? Rest extends keyof T[Head]
? T[Head][Rest]
: never
: never
: never;
// ===== 第三版(完全体):递归 + 混合终止 =====
type Get<T, P extends string> =
P extends `${infer Head}.${infer Rest}`
? Head extends keyof T // 还有下一段路径
? Get<T[Head], Rest> // 深入一层,递归
: never // 路径走歪了
: P extends keyof T // 没有点号 = 最后一段
? T[P] // 直接取值,终止
: never; // 键不存在
// ===== 验证 =====
type A = Get<Theme, "colors.primary">; // string ✅
type B = Get<Theme, "chart.line.width">; // number ✅
type C = Get<Theme, "chart">; // { line: {...} } ✅(中途停下也合法)
type D = Get<Theme, "colors.typo">; // never ⚠ 路径错误
type E = Get<Theme, "a.b.c">; // never ⚠
4.4 配套运行时函数
/**
* 类型安全的深层取值( lodash.get 的类型增强版)
* 实现:沿着路径逐层取,遇 undefined 短路
*/
function getValue<T, P extends string>(
obj: T,
path: P
): Get<T, P> {
return path.split(".").reduce<any>((acc, key) => acc?.[key], obj);
}
// ⚠ 实现内部用 any 是可以接受的(体操价值在签名)——
// 与 debounce 的例子同理:"对外类型精确,对内实现务实"
const theme: Theme = {
colors: { primary: "#0f0", danger: "#f00" },
chart: { line: { width: 2, smooth: true } }
};
const w = getValue(theme, "chart.line.width"); // w: number ✅
const p = getValue(theme, "colors.primary"); // p: string ✅
// getValue(theme, "chart.line.height"); // ❌ 编译期拦截 ⭐
4.5 进阶:数组下标支持(今日练习的种子)
// 目标:Get<Data, "list.0.temp"> —— 路径里含数组下标
interface Data { list: { temp: number }[] }
// 提示(不给完整答案,明天练习做):
// 1. 下标是字符串 "0" —— 需要判断 "字符串是不是纯数字"
// 2. 纯数字判断:S extends `${number}` 可以匹配!
// 3. 数组取元素:T extends readonly any[] ? T[number] : never
type IsIndex<S extends string> = S extends `${number}` ? true : false;
type A = IsIndex<"0">; // true
type B = IsIndex<"abc">; // false
// 拿着这两个零件 + Get 的骨架,动手改吧
五、工业实战场景
5.1 场景一:链式构建器(Chainable 的业务落地)
/**
* 大屏图表配置构建器:链式 API + 类型记忆
* 每一步 set 都被类型记住,get() 得到精确的配置对象
*/
class ChartConfigBuilder {
private config: Record<string, unknown> = {};
set<K extends string, V>(key: K, value: V): this & { [P in K]: V } {
(this.config as any)[key] = value;
return this as this & { [P in K]: V };
}
get(): Record<string, unknown> {
return { ...this.config };
}
}
const builder = new ChartConfigBuilder()
.set("width", 800)
.set("series", [{ type: "line" }]);
// builder 的类型累积了 { width: number } & { series: { type: string }[] }
// —— Chainable 题的模式搬进类,一行不用改思路
5.2 场景二:类型安全的 ECharts 主题取值
/**
* 主题配置的深层取值 + 空值兜底的类型表达
*/
type ThemePath = keyof Theme | `${keyof Theme & string}.${string}`;
function themeGet<P extends ThemePath>(path: P): Get<Theme, P> | undefined {
return getValue(theme, path);
}
const width = themeGet("chart.line.width");
// number | undefined —— 类型告诉你"可能取不到"
//(Get 返回 never 时合并 undefined = 安全访问的完整表达)
if (width !== undefined) {
width.toFixed(0); // ✅ 收窄后安全使用
}
5.3 场景三:表单校验器的字段级类型(FormState 进化)
/**
* 第 2 周的 FormState + 今天的 Get 组合:
* 校验规则的键类型直接从实体派生,嵌套字段也支持
*/
interface OrderForm {
customer: { name: string; vip: boolean };
items: { sku: string; qty: number }[];
}
type ValidationRule<T> = {
[K in keyof T]: T[K] extends object
? ValidationRule<T[K]> // 嵌套对象 → 递归规则
: (value: T[K]) => string | null; // 叶子 → 校验函数
};
const rules: ValidationRule<OrderForm> = {
customer: {
name: v => v.length > 0 ? null : "必填",
vip: () => null
},
items: [] // 数组字段的处理是 Day 21 练习
};
// 校验任何字段的函数签名:
function validateField<K extends string>(
form: OrderForm,
path: K,
rule: Get<ValidationRule<OrderForm>, K>
): string | null { /* ... */ return null; }
validateField(order, "customer.name", rules.customer.name); // ✅ 路径与规则类型联动
六、类比记忆:乐高 vs 快递地址
Chainable 链式累积 = 乐高拼装
┌──────────────────────────────────────┐
│ 每块积木(一次 option 调用) │
│ 拼上去的部分(交叉 & 新键)永远不掉 │
│ 拼得越多,整体形状越丰富 │
│ 同位置重拼 → Omit 掉旧块再放新块 │
└──────────────────────────────────────┘
Get<T, Path> = 快递地址逐级派送
┌──────────────────────────────────────┐
│ 地址:"chart.line.width" │
│ 第一程:省(chart)→ 进入这个对象 │
│ 第二程:市(line)→ 再进入 │
│ 第三程:区(width)→ 签收,拿到货 │
│ 地址写错(键不存在)→ 查无此处 never │
│ 每一程 = 一次递归 + 一次索引访问 │
└──────────────────────────────────────┘
七、常见坑点与最佳实践
坑点 1:DeepReadonly 漏判函数
// Function extends object === true!
type Bad<T> = T extends object ? { readonly [K in keyof T]: Bad<T[K]> } : T;
interface Svc { run: (x: number) => void }
type R = Bad<Svc>;
// { readonly run: { readonly apply: Function... } } ⚠ 函数被拆碎了
// ✅ 边界完备清单:Function → 数组 → 对象 → 原始类型(四分支)
坑点 2:Flip 的值类型约束
// 值要变成键 → 值必须是 string | number | symbol | boolean(可转字符串)
type FlipBad<T> = { [K in keyof T as T[K]]: K };
// ⚠ 若 T 的值是对象/数组类型 → as 处直接编译错误
// ✅ 约束先行:T extends Record<string, string | number | boolean>
// 值域明确才能"搬进键位"
坑点 3:Chainable 的 get() 返回类型写死
// 题目初学者的答案:
type Chainable = {
option<K extends string, V>(key: K, value: V): Chainable & { [P in K]: V };
get(): { [P in string]: unknown }; // ⚠ 拿到的是宽泛类型
};
// chain.option("foo", 123).get().foo // unknown ⚠ 信息丢失
// 题目用例要求 get() 返回累积后的精确类型——
// 正解思路:Chainable 自身泛型化(把累积的形状作为类型参数传递):
type Chainable2<T = {}> = {
option<K extends string, V>(key: K, value: V): Chainable2<T & { [P in K]: V }>;
get(): T;
};
// 这才是这道题的"完全体"答案——累积的形状通过泛型参数流转 ⭐
坑点 4:Get 的终止分支顺序
// 先判"最后一段"再判"还有剩余"会怎样?
type GetWrong<T, P extends string> =
P extends keyof T ? T[P] : P extends `${infer H}.${infer R}` ? ... : never;
// ⚠ "a.b" 整体先去匹配 keyof T —— 几乎必然失败才走拆分
// 逻辑上"勉强"能跑,但路径里有对象键含点的边界会翻车
// ✅ 惯例:先拆(递归优先),拆不动再整体匹配(终止在后)
坑点 5:实现层的 any 焦虑
// 体操的价值观澄清:
// 类型体操的价值 = 对外签名精确(调用方受益)
// 实现内部少量 any = 务实的工程选择(不是失败)
// 反例才是失败:签名 any(外面裸奔)+ 实现精巧(没人受益)
最佳实践清单
递归对象题先列边界清单:函数/数组/对象/原始,四分支各就各位
链式累积两件套:
this & { 新增 }或泛型参数T & { 新增 }流转Get 类工具先写两层硬编码版,看清形状再泛化递归
as 重映射记两个方向:键→键(改名)、值→键(Flip)
每题对照"昨天的执行模拟"习惯:Get<“a.b.c”> 走两轮递归 + 一轮终止
八、自测挑战
Q1:Chainable 的链式记忆靠什么机制?同名键覆盖的严格版怎么写?
Q2:Chainable2<T = {}> 泛型版比"交叉累积版"好在哪?get() 的返回类型差异是什么?
Q3:Flip 为什么必须约束 T extends Record<string, string | number | boolean>?
Q4:DeepReadonly 为什么必须先判 Function?Function extends object 是 true 还是 false?
Q5:DeepReadonlyFull 的数组分支用了昨天的什么机制?
Q6:手写 Get<T, P>(不看第四节),并给 "a.b.c" 写执行模拟。
Q7:Get<Theme, "colors.primary"> 的两轮递归各发生了什么?终止分支是什么形状触发的?
Q8:IsIndex<"0"> 为什么是 true?它怎么帮助 Get 支持数组下标?
Q9:为什么说"体操的价值在签名不在实现"?举一个正面例子和一个反面例子。
Q10:映射类型作用在元组和对象上分别是什么行为?这个特性今天哪道题用到了?
九、总结与知识图谱
对象体操(Day 19)
│
├── 武器清单
│ ├── 映射 + as 重映射(键→键 / 值→键)
│ ├── 索引访问(单键 / keyof 联合)
│ ├── 交叉类型(累积构建)
│ └── 条件 + 递归(判形状 + 深入)
│
├── 冷知识
│ ├── 映射元组 = 逐元素变换(形状保持)
│ └── keyof 元组 = 下标字符串 + 方法名
│
├── 题目战绩
│ ├── Parameters(3312):10 秒验收
│ ├── Chainable(12):交叉累积 / 泛型流转两版
│ ├── Flip(459):as 的值→键
│ └── DeepReadonly(9):四分支边界完备
│
├── Get<T, Path>(压轴工业工具)
│ ├── 三步实现法:硬编码两层 → 递归泛化 → 配运行时
│ ├── 路径解析 = Day 18 字符串拆解 + 索引递归
│ └── 进阶:数组下标(IsIndex)
│
└── 工业落地
├── ChartConfigBuilder:链式配置构建
├── themeGet:主题深层取值 + 空值类型
└── ValidationRule:嵌套校验规则派生
一句话总结:对象体操的三板斧是"as 重映射换键、交叉类型攒形状、递归 + 索引深入层"——攒够了它们,Get<T, Path> 这样的工业级工具就能从需求到类型一气呵成。
延伸阅读
下一步
明天(Day 20)暂停做题,进入理论深水区:协变与逆变——类型系统的"方向感"。它解释一个反直觉现象:函数参数为什么可以"反着"赋值。学完它,你对类型兼容的理解将从"结构相同"升级为"方向正确"。
Get<T, Path> 写完的那一刻你会明白:体操不是炫技,是把运行时的事提前到编译时。
每天花 2 小时,28 天通关 TypeScript 深入。第 3 周第 5 天,工业级工具到手!