【TS】day21-boss-medium-sprint

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

TypeScript 第三周 BOSS 战 — medium 冲刺,一周武功的总验收

前六天你分别练了元组(Day 16)、递归(Day 17)、字符串(Day 18)、对象(Day 19)四大套路,还补了协变逆变理论(Day 20)。今天没有新知识——只有考场。10 道 medium 精选题(含 3 道旧题默写验收 + 1 道终极挑战 Permutation + 1 道自命题),全部亲手通过,第 3 周正式通关。今日铁律加倍:每道题先自己写 15 分钟,卡住看提示,不看答案


目录


一、战斗目标与规则

1.1 战斗目标

今日一口气完成 10 道 medium 精选(前六天没做过的新题 + 旧题默写):
全部亲手通过 = 第 3 周通关(本周累计 25+ 题)

1.2 冲刺题目清单(按难度梯度排序)

# 题号 题目 梯队 考察点 来源
1 2 GetReturnType 热身 infer 反解返回值 新题(换皮验收)
2 3 Omit 热身 as 重映射过滤 新解法
3 17 TrimRight 标准 递归字符串啃食 新题(Trim 镜像)
4 62 TypeLookup 标准 分配律按字段筛选 新题
5 191 AppendArgument 标准 函数类型重构 新题
6 898 Includes 硬仗 Equal + 递归扫描 Day 17 默写
7 9 DeepReadonly 硬仗 递归映射 + 函数边界 Day 19 默写
8 12 Chainable 硬仗 交叉类型累积 Day 19 默写
9 296 Permutation 终极 分配律 + 递归 + never 新题 ⭐⭐⭐
10 - HubTopic 自命题 综合 模板字面量 + 业务落地 自命题

1.3 今日节奏(2 小时制升级为冲刺制)

- 09:00-09:10  装备检查(复盘四套路口诀)
- 09:10-09:30  第一梯队 2 题(热身)
- 09:30-10:30  第二梯队 3 题(标准 medium)
- 10:30-11:00  休息 + 复盘前 5 题套路归类
- 11:00-12:00  第三梯队 3 题默写(硬仗)
- 14:00-15:00  Permutation 终极挑战(预留整块时间)
- 15:00-15:30  自命题 HubTopic
- 15:30-16:30  周总结博客《类型体操一周训练报告》

1.4 判题工具(所有题通用)

/** 类型层严格相等判断(判题的基石) */
type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends
  (<T>() => T extends Y ? 1 : 2) ? true : false;

/** 用例失败时触发编译错误 */
type Expect<T extends true> = T;

二、冲刺前的装备检查

开考前把四大套路的口诀默背一遍——每道题用到时直接索引:

2.1 装备一:元组口诀(Day 16)

// "元组是列表,infer 拆头尾"
type Head<T extends readonly any[]> = T extends [infer F, ...any[]] ? F : never;
type Tail<T extends readonly any[]> = T extends [any, ...infer R] ? R : [];
type Last<T extends readonly any[]> = T extends [...any[], infer L] ? L : never;
type Init<T extends readonly any[]> = T extends [...infer R, any] ? R : [];

2.2 装备二:递归口诀(Day 17)

递归三律:
1. 终止条件——什么时候停?(元组空了 / 字符串没了 / 联合耗尽成 never)
2. 规模收敛——每轮怎么变小?(剥一个头 / 啃一个字符 / Exclude 一个成员)
3. 产出位置——递归调用直接成为结果的一部分(不是"顺手"调用)

2.3 装备三:字符串口诀(Day 18)

// "模板串即正则,infer 即捕获组"
type S1 = "hub:device/CNC-001" extends `hub:${infer Topic}` ? Topic : never;
// Topic = "device/CNC-001"
// 注意:裸 ${infer A}${infer B} 的 A 只匹配一个字符(非贪婪)——特性不是 bug

2.4 装备四:对象口诀(Day 19)

// "映射遍历键,as 重映射改键/删键"
type RenameKeys<T> = { [K in keyof T as `get${Capitalize<K & string>}`]: T[K] };
// 删键:as P extends K ? never : P(never 的键会被映射丢弃)

2.5 终极心法:解题三步法(Day 15)

1. 形状分析——期望的输出长什么样?(元组/联合/对象/函数)
2. 分解路径——输入到输出要拆几步?哪步用条件/infer/映射/递归?
3. 递归检查——能递归吗?终止条件是什么?每轮规模收敛吗?

装备齐了,开战。


三、第一梯队:热身题(10 分钟内每题)

3.1 第 1 题:GetReturnType(medium 第 2 题)

题目

// 实现 GetReturnType<T>:提取函数类型 T 的返回类型
type MyFunc = () => { name: string; age: number };

type Result = GetReturnType<MyFunc>;
// 期望:{ name: string; age: number }

// 额外用例:
type Cases = [
  Expect<Equal<GetReturnType<() => string>, string>>,
  Expect<Equal<GetReturnType<(v: number) => void>, void>>,
  Expect<Equal<GetReturnType<(v: any) => never>, never>>,
];

自己先写 15 分钟(提示:infer 放在返回值位置)

参考解法与拆解

/**
 * 提取函数返回类型:infer R 捕获返回值位置
 * 第 2 周手写过 MyReturnType——本题是"换皮验收"
 */
type GetReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
//                        └────────┬─────────────┘      └┬┘
//                          匹配任意函数签名        捕获返回值类型

执行过程

GetReturnType<() => string>
→ (() => string) extends (...args: any[]) => infer R ?
→ 匹配成功,R 推断为 string
→ string ✅

易错点

// ❌ 坑 1:只写 T extends Function —— 无法用 infer 捕获
type Bad1<T> = T extends Function ? ??? : never;   // 没有捕获位置

// ❌ 坑 2:非函数输入要返回 never(不是 any)
type Bad2<T> = T extends (...args: any[]) => infer R ? R : any;
type R = Bad2<number>;   // any ⚠ 判题用 Equal 会失败(any 与任何类型都不严格相等)

// ✅ 兜底分支用 never

套路归类

家族:函数反解(infer 位置捕获)
同族题:Parameters(参数)、Awaited(Promise 值)、AppendArgument(今日第 5 题)

3.2 第 2 题:Omit(medium 第 3 题)

题目

// 实现 MyOmit<T, K>:从 T 中剔除 K 指定的属性
interface Todo {
  title: string;
  description: string;
  completed: boolean;
}

type Result = MyOmit<Todo, "description">;
// 期望:{ title: string; completed: boolean }

自己先写 15 分钟

第一反应(第 2 周的老解法):

// 老解法:Pick + Exclude 组合
type MyOmit1<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
// 能过,但这题的 K 不约束 keyof T(K extends keyof any)——老解法会报错!

参考解法与拆解(⭐ as 重映射新解法)

/**
 * as 重映射版 Omit:遍历时把要删的键映射成 never
 * never 键会被映射类型自动丢弃——这是"过滤"的官方姿势
 */
type MyOmit<T, K extends keyof any> = {
  [P in keyof T as P extends K ? never : P]: T[P];
};
//   └─┬─┘ └──────────┬──────────┘
//   遍历键     as 后面的表达式决定"新键名"
//             命中 K → never → 该键被丢弃

逐行执行过程MyOmit<Todo, "description">):

keyof Todo = "title" | "description" | "completed"

映射遍历:
- P = "title"        → "title" extends "description" ? never : "title"
                     → "title" ✅ 保留
- P = "description"  → "description" extends "description" ? never : ...
                     → never ❌ 丢弃(never 键自动消失)
- P = "completed"    → "completed" ✅ 保留

结果:{ title: string; completed: boolean } ✅

两种解法对比

// 解法 A(组合派):可读性好,但依赖 Pick/Exclude 两个前置工具
type MyOmitA<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

// 解法 B(体操派):一步到位,K 的约束更宽(keyof any = string|number|symbol)
type MyOmitB<T, K extends keyof any> = {
  [P in keyof T as P extends K ? never : P]: T[P];
};

// B 的优势场景:K 里允许出现 T 没有的键(宽松过滤)
type R1 = MyOmitB<Todo, "description" | "no-exist">;   // ✅ 不报错
type R2 = MyOmitA<Todo, "description" | "no-exist">;   // ❌ 编译错误

套路归类

家族:对象映射 + as 重映射过滤
同族题:Pick(正向保留)、GetReadonlyKeys(条件分组)
工业场景:DTO 瘦身(User → UserPublic 剔除 password)

第一梯队收官:2 题应该 20 分钟内完成。如果超过——回去翻 Day 12 的 as 重映射一节。


四、第二梯队:标准 medium(15-20 分钟每题)

4.1 第 3 题:TrimRight(medium 第 17 题)

题目

// 实现 TrimRight<S>:去除字符串右侧的空白
type Result = TrimRight<"  hello  ">;      // "  hello"
type Result2 = TrimRight<"world\t\n  ">;   // "world"

type Cases = [
  Expect<Equal<TrimRight<"str">, "str">>,
  Expect<Equal<TrimRight<" str     ">, " str">>,
  Expect<Equal<TrimRight<"     str   ">, "     str">>,
];

自己先写 15 分钟(提示:Day 18 的 Trim 是"双侧啃",本题只啃右边)

参考解法与拆解

/**
 * 只去右侧空白:模式匹配"尾字符是否是空白",是则剥掉继续递归
 * 注意空白包括:空格 " "、换行 "\n"、制表符 "\t"
 */
type TrimRight<S extends string> =
  S extends `${infer Rest}${" " | "\n" | "\t"}`
    ? TrimRight<Rest>
    : S;
//    ↑ 尾字符不是空白 → 达到终止条件,返回自身

逐层执行过程TrimRight<"str ">):

第 1 轮:"str  " extends `${infer Rest}空格` ?
        → Rest = "str "(匹配成功,剥掉最后一个空格)
第 2 轮:"str " extends `${infer Rest}空格` ?
        → Rest = "str"
第 3 轮:"str" extends `${infer Rest}空白` ?
        → 尾字符是 "r",匹配失败 → 返回 "str" ✅

关键细节:联合模式的位置

// ⭐ 模板串里可以放联合类型——等价于多个模式同时尝试:
S extends `${infer Rest} `      // 模式 1:尾空格
  | `${infer Rest}\n`           // 模式 2:尾换行
  | `${infer Rest}\t`           // 模式 3:尾制表符
  ? TrimRight<Rest> : S;

// 上面三种模式可以合并写成(更紧凑):
S extends `${infer Rest}${" " | "\n" | "\t"}` ? TrimRight<Rest> : S;
// 联合模式 = "模式们" —— 匹配其中任意一个即成功

与 Day 18 Trim 的对照(双侧版)

// Day 18 的双侧 Trim(复习):
type Trim<S extends string> =
  S extends `${" " | "\n" | "\t"}${infer Rest}` | `${infer Rest}${" " | "\n" | "\t"}`
    ? Trim<Rest>
    : S;
// 头尾两个方向都尝试——哪边有空白剥哪边

// 本题只需要右侧那一半分支——"裁剪" Day 18 的解法即可
// 这就是"镜像题"的意义:验证你是理解了套路,还是背了答案

套路归类

家族:递归字符串啃食
同族题:Trim(双侧)、TrimLeft(左侧)、ReplaceAll(中间啃食)
工业场景:CSV 解析器、用户输入规范化

4.2 第 4 题:TypeLookup(medium 第 62 题)

题目

// 实现 LookUp<U, T>:从联合类型 U 中筛选出 type 字段等于 T 的成员
interface Cat { type: "cat"; breeds: "Abyssinian" }
interface Dog { type: "dog"; breeds: "Flandraise" }

type Result = LookUp<Cat | Dog, "dog">;
// 期望:Dog

type Cases = [
  Expect<Equal<LookUp<Cat | Dog, "dog">, Dog>>,
  Expect<Equal<LookUp<Cat | Dog, "cat">, Cat>>,
];

自己先写 15 分钟(提示:分配律——联合遇到 extends 会自动逐个成员判断)

参考解法与拆解

/**
 * 按判别字段筛选联合成员
 * 核心:裸类型参数的分配律——U extends ... 时 U 会拆开逐个匹配
 */
type LookUp<U, T extends U["type"]> =
  U extends { type: T } ? U : never;
//  ↑ 裸 U 触发分配律            匹配的保留,不匹配的变 never(被联合丢弃)

逐个成员执行过程LookUp<Cat | Dog, "dog">):

分配律展开(联合自动逐个成员判断):

- U = Cat:Cat extends { type: "dog" } ?
          Cat 的 type 是 "cat","cat" 不满足 "dog"
          → never ❌

- U = Dog:Dog extends { type: "dog" } ?
          Dog 的 type 正是 "dog" ✅
          → Dog ✅

合并结果:never | Dog = Dog ✅
(never 是联合的"零元"——自动消失)

深入:为什么 never 会消失?

// 联合运算的数学性质:
type R1 = never | Dog;         // Dog(never 是联合的吸收元/零元)
type R2 = never | string;      // string
type R3 = string | never;      // string

// 这个性质是"过滤"类体操的地基:
// "把不要的成员映射成 never" = "从联合中删除"
// 你已经在 Exclude 里见过它:
type MyExclude<U, K> = U extends K ? never : U;

工业场景:判别联合的编译期筛选

// 真实项目里的应用——设备事件总线的类型筛选:
interface TempEvent { kind: "temp"; value: number }
interface StatusEvent { kind: "status"; text: string }
interface AlarmEvent { kind: "alarm"; level: 1 | 2 | 3 }

type DeviceEvent = TempEvent | StatusEvent | AlarmEvent;

// 筛选某类事件(LookUp 的实战版):
type LookUp2<U, K extends U["kind"]> = U extends { kind: K } ? U : never;

type OnlyAlarm = LookUp2<DeviceEvent, "alarm">;
// AlarmEvent —— 然后安全地访问 level 字段:
declare const e: OnlyAlarm;
e.level.toFixed();   // ✅ number(不是 "1|2|3|undefined 的联合收窄")

套路归类

家族:分配律过滤
同族题:Exclude(按类型剔除)、Extract(按类型保留)、Without(元组版剔除)
工业场景:判别联合筛选、事件类型路由

4.3 第 5 题:AppendArgument(medium 第 191 题)

题目

// 实现 AppendArgument<F, A>:给函数类型 F 追加一个参数 A(插在最前)
type Result = AppendArgument<(a: number, b: string) => number, boolean>;
// 期望:(x: boolean, a: number, b: string) => number

type Cases = [
  Expect<Equal<AppendArgument<() => void, undefined>, (x: undefined) => void>>,
  Expect<Equal<
    AppendArgument<(a: number) => void, undefined>,
    (x: undefined, a: number) => void
  >>,
];

自己先写 15 分钟(提示:函数类型 = 参数元组 + 返回值,两处都要 infer)

参考解法与拆解

/**
 * 函数类型的"重构":拆出参数元组和返回值,再重新组装
 * 元组前插用变长展开语法:[A, ...P]
 */
type AppendArgument<F extends (...args: any) => any, A> =
  F extends (...args: infer P) => infer R
    ? (x: A, ...args: P) => R
    : never;
//      └─┬─┘         ↑
//      新参数插最前   原参数元组展开跟在后面

执行过程AppendArgument<(a: number, b: string) => number, boolean>):

第 1 步:模式匹配函数签名
  F = (a: number, b: string) => number
  匹配 (...args: infer P) => infer R
  → P 推断为 [a: number, b: string](参数自动收拢为元组!)
  → R 推断为 number

第 2 步:重组新函数
  (x: A, ...args: P) => R
  = (x: boolean, a: number, b: string) => number ✅

关键洞察:参数会自动"收拢"成元组

// ⭐ 这是本题最值得记住的知识点:
declare function f(a: number, b: string): number;

type P1 = Parameters<typeof f>;   // [a: number, b: string]
//                                    ↑ 散落的参数被 infer 收拢成元组

// 所以"操作函数参数" = "操作元组":
//   前插参数 = 元组前插元素([A, ...P])
//   删参数   = 元组剥皮(Tail<P>)
//   交换顺序 = 元组 reverse(需要递归,hard 题了)

变式练习(检验你是否真懂)

// 变式 1:追加到末尾(不是最前)
type AppendLast<F extends (...args: any) => any, A> =
  F extends (...args: infer P) => infer R
    ? (...args: [...P, A]) => R
    : never;

// 变式 2:改返回值(参数不动)
type SetReturn<F extends (...args: any) => any, R> =
  F extends (...args: infer P) => infer _ ? (...args: P) => R : never;

// 变式 3:删第一个参数
type DropFirst<F extends (...args: any) => any> =
  F extends (x: any, ...args: infer P) => infer R
    ? (...args: P) => R
    : never;

套路归类

家族:函数反解 + 元组重组
同族题:Parameters、ReturnType、Get(今日第 7 题 DeepReadonly 的表亲)
工业场景:中间件签名包装、装饰器类型、API 适配层

第二梯队收官:3 题应在 50 分钟内完成。这 3 题分别代表字符串、联合、函数三个方向——medium 的标准难度就是这样,单个知识点不难,难的是组合。


五、第三梯队:硬仗(30 分钟每题)

接下来 3 题是旧题默写——Day 17/19 做过。规则升级:不看任何旧笔记,从零重写。隔天还能默写出来,才算真的学会(对抗"通过错觉")。

5.1 第 6 题:Includes(medium 第 898 题)

题目(回忆)

// 实现 Includes<T, L>:判断 L 是否在元组 T 中(严格相等语义)
type Cases = [
  Expect<Equal<Includes<['Kars', 'Alucard'], 'Kars'>, true>>,
  Expect<Equal<Includes<['Kars', 'Alucard', 'Kars'], 'Kars'>, true>>,
  Expect<Equal<Includes<['Kars', 'Alucard'], 'Dio'>, false>>,
  Expect<Equal<Includes<[{ [x: 'Kars']: string }], { [x: 'Kars']: string }>, false>>,
  Expect<Equal<Includes<[{}], { a: 'A' }>, false>>,
  Expect<Equal<Includes<[boolean], false>, false>>,      // ⚠ boolean ≠ false
  Expect<Equal<Includes<[false], boolean>, false>>,      // ⚠ false ≠ boolean
  Expect<Equal<Includes<[any], unknown>, false>>,        // ⚠ any 的坑
];

默写(15 分钟,不看下文)

参考解法与拆解

/** 官方 Equal(严格相等) */
type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends
  (<T>() => T extends Y ? 1 : 2) ? true : false;

/**
 * 递归扫描元组:每次剥头比较,头不是就递归剥剩下的
 */
type Includes<T extends readonly any[], L> =
  T extends [infer F, ...infer R]      // 剥头
    ? Equal<F, L> extends true         // 严格比较头部
      ? true                           // 找到 → 终止
      : Includes<R, L>                 // 没找到 → 递归扫尾
    : false;                           // 元组耗尽 → 不存在

默写时的三大检查点(漏一个就判题失败):

1. 用 Equal 而不是 extends 判等
   → extends 会被 any 双向匹配坑(Includes<[any], unknown> 会错误返回 true)

2. 递归三律逐条检查:
   终止条件:T 不再匹配 [infer F, ...infer R](空元组)→ false ✅
   规模收敛:每轮剥掉一个头,R 比 T 短 ✅
   产出位置:递归调用在三元分支上直接产出 ✅

3. readonly 兼容:签名 T extends readonly any[](题目 as const 传入时不报错)

为什么必须用 Equal?(用例逐个拆)

// 用例:Includes<[any], unknown>
// ❌ 如果用 extends 判等:
any extends unknown ? true : false   // true ⚠(any 双向匹配)
// 结果错误返回 true

// ✅ Equal 的原理:利用函数签名的内部同一性
// <T>() => T extends X ? 1 : 2 这个类型的"身份"由 X 唯一决定
// any 和 unknown 产生的签名不同 → false ✅

// 用例:Includes<[boolean], false>
// boolean 是 true|false 的联合,false 只是其中一员
// Equal<boolean, false> = false ✅(extends 也会误判)

5.2 第 7 题:DeepReadonly(medium 第 9 题)

题目(回忆)

// 实现 DeepReadonly<T>:递归地给所有嵌套属性加 readonly
type Cases = [
  Expect<Equal<DeepReadonly<{ a: { b: { c: 1 } } }>, { readonly a: { readonly b: { readonly c: 1 } } }>>,
  Expect<Equal<DeepReadonly<{ a: () => 1 }>, { readonly a: () => 1 }>>,   // ⚠ 函数不能被映射
];

默写(15 分钟,不看下文)

参考解法与拆解

/**
 * 深度只读:对象 → 递归映射加 readonly
 * 关键边界:函数类型不能进映射(函数会被映射成"丢失可调用性"的对象)
 */
type DeepReadonly<T> =
  T extends Function                       // 边界 1:函数原样返回
    ? T
    : T extends object                     // 边界 2:对象才递归映射
      ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
      : T;                                 // 边界 3:原始类型原样返回

执行过程DeepReadonly<{ a: { b: 1 }; f: () => void }>):

第 1 层:{ a: {...}; f: () => void } 是对象(不是函数)
  → 映射:
     - K = "a" → readonly a: DeepReadonly<{ b: 1 }>
                → 递归:{ b: 1 } 是对象
                  → readonly b: DeepReadonly<1>
                  → 1 不是对象 → 1
                → { readonly b: 1 }
     - K = "f" → readonly f: DeepReadonly<() => void>
               → 递归:() => void 是函数 → 原样返回 ✅
               → () => void

结果:{ readonly a: { readonly b: 1 }; readonly f: () => void } ✅

函数边界的对照实验(没有这个分支会发生什么):

// ❌ 没有函数分支的版本:
type BadDR<T> = T extends object ? { readonly [K in keyof T]: BadDR<T[K]> } : T;

type R = BadDR<{ f: () => void }>;
// 结果:{ readonly f: {} } ⚠⚠
// 函数被 keyof 遍历后,只剩下"函数自身的属性"(length、name 等)
// 可调用性完全丢失!调用 r.f() 直接编译报错

// 记住这个边界:**任何"递归对象"的体操都要先检查函数**

5.3 第 8 题:Chainable(medium 第 12 题)

题目(回忆)

// 实现 Chainable:可链式调用 option() 累积选项,最后 get() 取出全部
declare const chain: Chainable;

const result = chain
  .option("foo", 123)
  .option("bar", { value: "Hello World" })
  .option("name", "type-challenges")
  .get();

type Cases = [
  Expect<Equal<typeof result, {
    foo: number;
    bar: { value: string };
    name: string;
  }>>
];

默写(15 分钟,不看下文)

参考解法与拆解

/**
 * 链式选项:每次 option() 返回"自身 & 新键值"
 * 交叉类型(&)像滚雪球一样累积配置
 */
type Chainable = {
  option<K extends string, V>(key: K, value: V): Chainable & { [P in K]: V };
  //             ↑ 锁住键          ↑ 返回:原链 + 新增键(交叉累积)
  get(): Record<string, unknown>;
};

类型流动过程(跟随上面的调用链):

chain                                  : Chainable
.option("foo", 123)                    : Chainable & { foo: number }
.option("bar", { value: "..." })       : Chainable & { foo: number } & { bar: { value: string } }
.option("name", "type-challenges")     : Chainable & { foo: number } & { bar: {...} } & { name: string }
.get()                                 : Record<string, unknown>?

——等等,get() 返回 Record<string, unknown>,用例怎么过的?

关键机制:泛型的"每次调用重新推断"

// option 的返回类型是 Chainable & { [P in K]: V }
// 其中 K、V 在【下一次调用 option 时】重新推断

// 最后一环 get() 的类型:
// Chainable & { foo: number } & { bar: {...} } & { name: string }
// get(): Record<string, unknown>

// ⚠ 但用例期望 get() 返回具体类型!所以真正的解法要泛型化 Chainable:
type Chainable2<T = {}> = {
  option<K extends string, V>(key: K, value: V): Chainable2<T & { [P in K]: V }>;
  //                                            ↑ 把累积结果 T 传给下一环
  get(): T;   // ← get 直接吐出累积的 T
};

// 现在类型流动变成:
chain                                : Chainable2<{}>
.option("foo", 123)                  : Chainable2<{ foo: number }>
.option("bar", { value: "..." })     : Chainable2<{ foo: number } & { bar: { value: string } }>
.option("name", "...")               : Chainable2<{ foo } & { bar } & { name: string }>
.get()                               : { foo: number } & { bar: { value: string } } & { name: string }
                                     // 交叉类型与期望的展开形式结构等价 → Equal 通过 ✅

默写检查点

1. option 返回的是 Chainable<T & {新键}>,不是 Chainable(泛型状态要传递)
2. get(): T(吐出累积状态)
3. T 的默认值是 {}(空对象起步)

第三梯队收官:3 题默写全部通过 = 前六天的学习成果真实有效。任何一题卡壳超过 30 分钟——标记该天(Day 17/19)需要重修。


六、终极挑战:Permutation(本周压轴)

这是 type-challenges 里被标记为 medium 的"守门员"题——无数人在这题上第一次怀疑人生。它综合了本周所有武器:分配律 + 递归 + never + Exclude。预留完整 30-60 分钟。

6.1 第 9 题:Permutation(medium 第 296 题)

题目

// 实现 Permutation<T>:把联合类型 T 的全排列输出为元组的联合
type Cases = [
  Expect<Equal<Permutation<'A'>, ['A']>>,
  Expect<Equal<Permutation<'A' | 'B' | 'C'>, ['A', 'B', 'C'] | ['A', 'C', 'B']
    | ['B', 'A', 'C'] | ['B', 'C', 'A'] | ['C', 'A', 'B'] | ['C', 'B', 'A']>>,
  Expect<Equal<Permutation<'B' | 'A' | 'C'>, ['A', 'B', 'C'] | ['A', 'C', 'B']
    | ['B', 'A', 'C'] | ['B', 'C', 'A'] | ['C', 'A', 'B'] | ['C', 'B', 'A']>>,
  Expect<Equal<Permutation<never>, []>>,   // ⚠ never 的特殊用例
];

先理解输出(形状分析)

输入:'A' | 'B' | 'C'(3 个成员的联合)
输出:6 个元组组成的联合(3! = 6 种排列)

手动模拟思路(值层面的伪代码):
perm(['A','B','C']):
  'A' 打头 + perm(['B','C'])
    'B' 打头 + perm(['C']) → ['A','B','C']
    'C' 打头 + perm(['B']) → ['A','C','B']
  'B' 打头 + perm(['A','C'])
    ... → ['B','A','C']、['B','C','A']
  'C' 打头 + perm(['A','B'])
    ... → ['C','A','B']、['C','B','A']

递归结构:
  - 每轮"选一个成员打头"
  - 剩余成员递归排列
  - 成员耗尽 → 空元组(终止)

翻译成类型(先自己试 30 分钟)

第一版尝试(自然的思路)

type Permutation1<T> =
  T extends never        // 联合耗尽?
    ? []
    : T extends T        // 分配律:逐个成员
      ? [T, ...Permutation1<Exclude<T, T>>]   // ← Exclude<T, T>???
      : never;
// 问题:T extends T 分支里 Exclude<T, T> = never(自己排除自己)
// 递归直接终止 → 只有单元素排列 ❌

卡点分析

分配律分支里的 T 是【单个成员】(比如 'A')
但我们需要的"剩余成员"是【除 'A' 之外的原始联合】
——单成员 T 里已经丢失了原始联合的信息!

解法:引入第二个类型参数 U = T,在分配【之前】把原始联合存起来

参考解法(⭐ 本周最精妙的三行)

/**
 * 联合的全排列:U 保存"还没用过的成员",每轮从 U 中选一个打头
 * [T] extends [never]:判断联合是否耗尽(不能裸写 T extends never——会被分配)
 */
type Permutation<T, U = T> =
  [T] extends [never]                      // ① 终止:原始联合已耗尽
    ? []                                   //    → 空元组(排列的基底)
    : T extends U                          // ② 分配律:T 逐个成员展开
      ? [T, ...Permutation<Exclude<U, T>>] // ③ T 打头,剩余成员递归排列
      : never;
//        ↑ Exclude 从 U(未用成员)中剔除当前 T

逐行拆解(画分配递归树)

执行过程Permutation<'A' | 'B'>):

初始:T = 'A' | 'B',U = 'A' | 'B'

第 1 层:[T] extends [never]?
        ['A'|'B'] 不是 [never] → 继续分配
        T extends U(分配律展开):

  分支 T='A':
    ['A', ...Permutation<Exclude<'A'|'B', 'A'>>]
    = ['A', ...Permutation<'B'>]
    
    递归:T='B',U='B'
      [T] extends [never]?否
      'B' extends 'B'?
        ['B', ...Permutation<Exclude<'B','B'>>]
        = ['B', ...Permutation<never>]
        
        递归:T=never,U=never
          [never] extends [never]?✅ 是!
          → [](终止,空元组)
        
        = ['B', ...[]] = ['B']
    = ['A', 'B'] ✅

  分支 T='B':(对称)
    ['B', ...Permutation<'A'>] = ['B', 'A'] ✅

最终合并:['A','B'] | ['B','A'] ✅

三个关键技巧逐个讲透

// ⭐ 技巧 1:[T] extends [never](元组包裹防分配)
type A1 = never extends never ? true : false;        // true(但永远触发,无信息)
// 裸 T extends never 时,T=never 会走"分配特殊分支"——联合为空时
// 整个表达式直接返回 never,而不是 true!
type A2<T> = T extends never ? true : false;
type A3 = A2<never>;                                  // never ⚠(不是 true!)

// 包裹成元组阻止分配:
type A4<T> = [T] extends [never] ? true : false;
type A5 = A4<never>;                                  // true ✅
// 这是判断"联合是否耗尽"的官方姿势

// ⭐ 技巧 2:U = T 默认参数(快照原始联合)
// 分配律展开后 T 变成单个成员,原始联合信息丢失
// U 在展开【前】绑定,之后一直持有完整列表

// ⭐ 技巧 3:Exclude<U, T>("用掉就划掉")
// 每选一个成员打头,就从备选池 U 中划掉它
// 划到空(never)→ 终止条件触发 → 收敛

递归树可视化(3 成员版)

Permutation<'A'|'B'|'C'>(U = ABC)

├─ 'A' 打头 → Permutation<'B'|'C'>(U = BC)
│   ├─ 'B' 打头 → Permutation<'C'>(U = C)
│   │   └─ 'C' 打头 → Permutation<never> → []
│   │   → ['C']
│   → ['B','C'] ──拼上头── ['A','B','C'] ✅
│   └─ 'C' 打头 → ... → ['B','A','C']... 
│      → ['C','B'] ──拼上头── ['A','C','B'] ✅
│
├─ 'B' 打头 → ...(对称展开)→ ['B','A','C']、['B','C','A']
│
└─ 'C' 打头 → ...(对称展开)→ ['C','A','B']、['C','B','A']

共 3! = 6 个元组的联合 ✅

套路归类

家族:分配律 + 递归 + never 三重奏(本周集大成者)
前置技能链:
  Day 12 分配律(裸类型参数展开)
  → Day 15 Equal([T] 包裹防分配的近亲技巧)
  → Day 17 递归三律(终止/收敛/产出)
  → Exclude(官方工具,never 吸收原理)

同族题:LengthOfString、Combination(组合,Permutation 的兄弟题)

恭喜

如果你独立做出了 Permutation——你已经具备了挑战 hard 题的资格。type-challenges 之旅远未结束,但第 3 周的通关条件已经达成


七、自命题:给 dataHub 装上类型安全的 topic

最后一题不是 type-challenges 的题,而是把本周所学落回你的工业项目——这才是学习类型体操的真正目的。

7.1 第 10 题:HubTopic 校验

需求

// dataHub 的订阅接口目前接收任意字符串:
function subscribe(topic: string): void {}

// 业务约定 topic 格式:hub:{域}/{id}
// 域只有三种:device / alert / metric

subscribe("hub:device/CNC-001");    // ✅ 合法
subscribe("hub:alert/TEMP-HIGH");   // ✅ 合法
subscribe("device/CNC-001");        // ❌ 缺 hub: 前缀——运行时才发现
subscribe("hub:user/CNC-001");      // ❌ user 不是合法域——运行时才发现
// 目标:把这两类错误拦截在编译期

解法(模板字面量类型的组合拳)

/**
 * 类型安全的 topic:模板字面量类型精确描述合法格式
 * 编译期即可拦截格式错误——不需要任何运行时代码
 */
type HubDomain = "device" | "alert" | "metric";

type HubTopic = `hub:${HubDomain}/${string}`;
//            └─┬─┘└────┬────┘└───┬──┘
//           字面量前缀  域联合    任意 id

function subscribe(topic: HubTopic): void {}

subscribe("hub:device/CNC-001");     // ✅
subscribe("hub:alert/TEMP-HIGH");    // ✅
subscribe("device/CNC-001");         // ❌ 编译错误(格式不匹配)
subscribe("hub:user/CNC-001");       // ❌ 编译错误(user 不在域联合中)

进阶:发布/订阅的类型联动

/**
 * 完整版:发布和订阅的 payload 类型也跟着 topic 走
 * 发错数据结构?编译期直接报错
 */
interface HubPayloads {
  device: { id: string; temp: number };
  alert: { level: 1 | 2 | 3; message: string };
  metric: { key: string; value: number; ts: number };
}

// 反解 topic 中的域 → 关联 payload 类型
type PayloadOf<T extends HubTopic> =
  T extends `hub:${infer D extends HubDomain}/${string}`
    ? HubPayloads[D]     // 索引访问:域 → payload 类型
    : never;

// 泛型联动版 subscribe:
function subscribe2<T extends HubTopic>(topic: T, handler: (data: PayloadOf<T>) => void): void {}

// 使用:handler 的参数类型自动跟随 topic!
subscribe2("hub:device/CNC-001", (d) => {
  d.temp.toFixed(1);      // ✅ d: { id: string; temp: number }
});

subscribe2("hub:alert/x", (d) => {
  d.level.toFixed();      // ✅ d: { level: 1|2|3; message: string }
});

subscribe2("hub:device/x", (d) => {
  d.message;              // ❌ 编译错误:device 的 payload 没有 message
});

用到的本周武器清单

✅ 模板字面量类型(Day 18)——描述 topic 的精确格式
✅ 联合类型(第 1 周复习)——HubDomain 约束合法域
✅ infer 捕获(Day 12/18)——从 topic 中反解出域 D
✅ infer + 约束(infer D extends HubDomain)——Day 18 的进阶语法
✅ 索引访问类型(第 1 周)——HubPayloads[D] 关联 payload
✅ 泛型函数联动(第 2 周)——T 在 topic 和 handler 之间流动

这就是类型体操的终点:不是炫技,而是让编译器替你守住业务规则的每一条缝。


八、周总结:类型体操一周训练报告

8.1 本周战果盘点

✅ Day 15:体操入门 + warm-up 4 题(HelloWorld/MyPick...)
✅ Day 16:元组 7 题(First/Last/Pop/Push/Concat/Length...)
✅ Day 17:递归 4 题(Includes/TupleToUnion/Promise.all/Flatten)
✅ Day 18:字符串 5 题(Trim/Replace/ReplaceAll/StringToUnion/事件名解析)
✅ Day 19:对象 5 题(Chainable/DeepReadonly/Flip/Get...)
✅ Day 20:协变逆变理论 + 兼容性实验报告
✅ Day 21:BOSS 战 10 题(含 Permutation)

本周累计:30+ 题通关(easy 20+ / medium 10+)

8.2 四大套路掌握度自评

套路 口诀 代表题 自评(1-5 分)
元组 infer 拆头尾 First / Last / Concat
递归 终止 + 收敛 + 产出 Includes / Flatten / Permutation
字符串 模板串即正则 Trim / ReplaceAll / Get<T, Path>
对象 映射遍历 + as 改键 Omit / Flip / DeepReadonly

低于 4 分的套路 → 下周 BOSS 战前重做该天全部题目。

8.3 写周总结博客《类型体操一周训练报告:从 0 到 medium》

推荐大纲

# 类型体操一周训练报告:从 0 到 medium

## 一、为什么练类型体操
- 不是炫技:是为了让编译器守住业务规则(举 HubTopic 的例子)

## 二、四大套路(每个配一道代表题的解题过程)
- 元组:infer 拆头尾
- 递归:终止 + 收敛 + 产出
- 字符串:模板串即正则
- 对象:映射 + as 重映射

## 三、印象最深的 3 道题
- Includes(Equal 的必要性)
- DeepReadonly(函数边界)
- Permutation([T] extends [never] 的分配陷阱)

## 四、踩过的坑
- 抄答案的"通过错觉"
- any 与 extends 判等的坑
- 递归爆栈的三大原因

## 五、类型体操对日常开发的改变
- 以前:any + 运行时兜底
- 现在:模板字面量 + 判别联合 + 泛型联动

8.4 下周预告

第 4 周将进入 TypeScript 工程化:
- tsconfig 深度配置(strict 全家桶逐项拆解)
- 模块系统(ESM/CJS 互操作的坑)
- 声明文件(.d.ts 的编写与发布)
- 类型测试(tsd / vitest 类型断言)
- 第 4 周 BOSS 战:给 typed-utils 补全测试 + 发布准备

九、常见坑点与最佳实践

坑点 1:默写时的"记忆幻觉"

// 重写旧题时"感觉记得"——提笔就卡
// 这是正常现象,说明当时是"看懂了"而不是"学会了"
// 处理:卡壳的那天标记重修,第二天再默写一次
// 标准:连续两天都能独立写出 = 真正掌握

坑点 2:Permutation 的 [T] extends [never] 写成裸 extends

// ❌ 裸写:never 走分配特殊分支,返回 never 而不是 true
type Bad<T> = T extends never ? [] : ...;
type R = Bad<never>;   // never ⚠(不是 []!)

// ✅ 元组包裹:阻止分配,never 就是 never
type Good<T> = [T] extends [never] ? [] : ...;
type R2 = Good<never>;   // [] ✅

// 心法:**判断联合是否为空,永远用 [T] extends [never]**

坑点 3:Chainable 忘记传递泛型状态

// ❌ option 返回裸 Chainable——每环都从零开始,get() 拿不到累积
type Bad = {
  option<K extends string, V>(key: K, value: V): Bad & { [P in K]: V };
  get(): Record<string, unknown>;   // ⚠ 全部丢失
};

// ✅ Chainable<T = {}> 把累积状态作为泛型参数传递
type Good<T = {}> = {
  option<K extends string, V>(key: K, value: V): Good<T & { [P in K]: V }>;
  get(): T;   // 累积结果完整吐出
};

坑点 4:冲刺节奏失控

症状:在 Permutation 上卡了 2 小时,后面的题全荒废
处理:
- 每题设 30 分钟闹钟,超时看【提示】(不是答案)
- 提示分级:方向提示 → 关键语法提示 → 完整答案
- 看完提示后合上,从零重写一遍(这才算做过这道题)

坑点 5:为过题而过题

// ❌ 用怪异技巧凑过判题(比如利用 any 匹配一切)
type Cheat<T> = any;   // 能"通过"很多题的 Equal?不能,Equal 对 any 严格

// ✅ 每道题过了之后问自己三个问题:
// 1. 这个解法为什么工作?(能逐行讲出执行过程)
// 2. 换个用例还成立吗?(自己造边界用例)
// 3. 这个套路还能用在哪?(联想到工业场景)
// 三个都答不上 = 白做

十、自测挑战

挑战 1(基础):默写 Includes

// 不看上文,从零写出 Includes 并通过:
type Test1 = Includes<[1, 2, 3], 2>;        // true
type Test2 = Includes<[any], unknown>;      // false(Equal 的功劳)
type Test3 = Includes<[boolean], true>;     // false

挑战 2(进阶):变式 AppendArgument

// 实现 PrependReturn:把函数返回值改成元组 [原返回值, A]
type PrependReturn<F extends (...args: any) => any, A> = ?
// 期望:
type R = PrependReturn<() => string, number>;
// () => [string, number]

挑战 3(进阶):Permutation 变式

// 实现 Combination<T>:联合的所有【组合】(不是排列,不要求全用)
// 提示:每个成员"选或不选",二叉递归
type Combination<T extends string> = ?
// 期望:
type R = Combination<'A' | 'B'>;
// [] | ['A'] | ['B'] | ['A', 'B']

挑战 4(综合):路径校验器

// 实现 ValidPaths<T>:把嵌套对象的所有合法路径生成为字符串联合
// 提示:递归 + 模板字面量 + 分配律(Day 19 Get 的镜像题)
interface Config {
  server: { host: string; port: number };
  db: { url: string };
}

type ValidPaths<T> = ?
type R = ValidPaths<Config>;
// "server" | "server.host" | "server.port" | "db" | "db.url"

挑战 5(论文级):讲出 Permutation 的每一行

给同事(或橡皮鸭)讲 Permutation:
1. 为什么需要第二个参数 U?
2. [T] extends [never] 为什么不能裸写?
3. Exclude<U, T> 每轮怎么收敛?
4. 画出 'A'|'B' 的完整递归树
讲不清楚的地方 = 你还没真懂的地方

十一、总结与知识图谱

11.1 今日核心收获

1. 10 道 medium 全通关 = 第 3 周正式毕业
2. 旧题默写验证了前六天的学习是真实的
3. Permutation 打通了分配律 + 递归 + never 的任督二脉
4. HubTopic 自命题完成了"体操 → 工程"的落地闭环

11.2 本周知识图谱(完整版)

类型体操(第 3 周)
│
├── 入门与工具(Day 15)
│   ├── type-challenges 平台与 Equal/Expect 判题
│   └── 解题三步法(形状分析 → 分解 → 递归检查)
│
├── 元组体操(Day 16)
│   ├── 变长元组展开 [...] 
│   └── 五虎将:Head/Tail/Last/Init/Union
│
├── 递归体操(Day 17)
│   ├── 递归三律(终止/收敛/产出)
│   ├── Includes(Equal + 递归扫描)
│   └── Promise.all(元组同构映射 + Awaited)
│
├── 字符串体操(Day 18)
│   ├── 模板字面量模式匹配
│   ├── 联合模式(多模式同时尝试)
│   └── Trim/Replace/ReplaceAll(递归啃食)
│
├── 对象体操(Day 19)
│   ├── Chainable(交叉类型滚雪球)
│   ├── DeepReadonly(递归映射 + 函数边界)
│   ├── Flip(键值反转)
│   └── Get<T, Path>(嵌套路径访问)
│
├── 协变逆变(Day 20)
│   ├── 协变(数组/返回值:方向一致)
│   ├── 逆变(参数:方向相反,strictFunctionTypes)
│   ├── 不变(读写容器)
│   └── in/out 注解(TS 4.7 显式变型)
│
└── BOSS 战(Day 21)★ 今日
    ├── GetReturnType / Omit(as 重映射)
    ├── TrimRight / TypeLookup / AppendArgument
    ├── 旧题默写(Includes / DeepReadonly / Chainable)
    ├── Permutation(分配律 + 递归 + never 三重奏)⭐
    └── HubTopic 自命题(体操落地工业场景)

11.3 一句话总结

类型体操的终点不是解出难题,而是让"业务规则"住进类型系统——当你写下 type HubTopic = \hub:${HubDomain}/${string}`` 的那一刻,100 个潜在的运行时 bug 已经死在了编译期。


明日预告:第 4 周开启 TypeScript 工程化——tsconfig 深度配置、模块系统、声明文件编写。从"会写类型"进化到"会搭建类型化的工程"。

评论