TypeScript 第三周 BOSS 战 — medium 冲刺,一周武功的总验收
前六天你分别练了元组(Day 16)、递归(Day 17)、字符串(Day 18)、对象(Day 19)四大套路,还补了协变逆变理论(Day 20)。今天没有新知识——只有考场。10 道 medium 精选题(含 3 道旧题默写验收 + 1 道终极挑战 Permutation + 1 道自命题),全部亲手通过,第 3 周正式通关。今日铁律加倍:每道题先自己写 15 分钟,卡住看提示,不看答案。
目录
- 一、战斗目标与规则
- 二、冲刺前的装备检查
- 三、第一梯队:热身题(10 分钟内每题)
- 四、第二梯队:标准 medium(15-20 分钟每题)
- 五、第三梯队:硬仗(30 分钟每题)
- 六、终极挑战:Permutation(本周压轴)
- 七、自命题:给 dataHub 装上类型安全的 topic
- 八、周总结:类型体操一周训练报告
- 九、常见坑点与最佳实践
- 十、自测挑战
- 十一、总结与知识图谱
一、战斗目标与规则
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 深度配置、模块系统、声明文件编写。从"会写类型"进化到"会搭建类型化的工程"。