【TS】day19-object-gymnastics

作者:mario 发布时间: 2026-08-31 阅读量:5 评论数:0

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(外面裸奔)+ 实现精巧(没人受益)

最佳实践清单

  1. 递归对象题先列边界清单:函数/数组/对象/原始,四分支各就各位

  2. 链式累积两件套this & { 新增 } 或泛型参数 T & { 新增 } 流转

  3. Get 类工具先写两层硬编码版,看清形状再泛化递归

  4. as 重映射记两个方向:键→键(改名)、值→键(Flip)

  5. 每题对照"昨天的执行模拟"习惯:Get<“a.b.c”> 走两轮递归 + 一轮终止


八、自测挑战

Q1Chainable 的链式记忆靠什么机制?同名键覆盖的严格版怎么写?

Q2Chainable2<T = {}> 泛型版比"交叉累积版"好在哪?get() 的返回类型差异是什么?

Q3Flip 为什么必须约束 T extends Record<string, string | number | boolean>

Q4DeepReadonly 为什么必须先判 FunctionFunction extends object 是 true 还是 false?

Q5DeepReadonlyFull 的数组分支用了昨天的什么机制?

Q6:手写 Get<T, P>(不看第四节),并给 "a.b.c" 写执行模拟。

Q7Get<Theme, "colors.primary"> 的两轮递归各发生了什么?终止分支是什么形状触发的?

Q8IsIndex<"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> 这样的工业级工具就能从需求到类型一气呵成。


延伸阅读

资源

说明

Mapped Types - as 重映射

官方 as 文档

type-challenges 题号:3312 / 12 / 459 / 9

今日题目

lodash 的 _.get 类型定义

对比社区方案( yours 更精确!)


下一步

明天(Day 20)暂停做题,进入理论深水区:协变与逆变——类型系统的"方向感"。它解释一个反直觉现象:函数参数为什么可以"反着"赋值。学完它,你对类型兼容的理解将从"结构相同"升级为"方向正确"。


Get<T, Path> 写完的那一刻你会明白:体操不是炫技,是把运行时的事提前到编译时。

每天花 2 小时,28 天通关 TypeScript 深入。第 3 周第 5 天,工业级工具到手!


评论