【TS】day15-type-gymnastics-intro

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

TypeScript 类型体操入门 — 训练营开营日:规则、方法与第一场热身赛

第 2 周 BOSS 战你手写了 15+ 个工具类型,信心正满。今天正式进入 type-challenges——全球 4 万+ star 的类型题库,TypeScript 面试的"力扣"。今天不追题量:把判题机制搞懂、把方法论立起来、用 4 道热身题找到手感。磨刀不误砍柴工。


目录


一、类型体操是什么

1.1 一句话定义

类型体操 = 不写一行运行时代码,纯靠类型系统在编译期完成"计算"的编程练习。

// 你熟悉的世界:运行时代码计算"值"
function capitalize(s: string): string {
  return s[0].toUpperCase() + s.slice(1);
}

// 类型体操的世界:类型代码计算"类型"
type Capitalize<S extends string> =
  S extends `${infer F}${infer R}` ? `${Uppercase<F>}${R}` : S;

type R = Capitalize<"cnc-001">;
// 悬停 R:类型显示 "Cnc-001" —— 编译器替你"算"完了

1.2 为什么值得花一周

// 理由 1:现代库的类型源码全是体操
// Vue3 的 defineComponent 推导 props 类型、
// Pinia 的 store 自动补全、zod 的 schema → 类型映射
// 看不懂体操 = 读不懂任何优秀库的源码

// 理由 2:业务里真实存在体操需求(大屏项目实例)
interface Theme {
  colors: { primary: string };
  chart: { line: { width: number } };
}
// 需求:getValue(theme, "chart.line.width") 要返回 number 类型
// —— 这就是一个不折不扣的体操题(Day 19 你会亲手实现)

// 理由 3:面试硬通货
// TS 中高级岗位的笔试/面试:"手写一个 MyPick/DeepReadonly"
// 和算法题一样,是筛选器

1.3 和第 2 周的关系

第 2 周 = 学习"语法与机制"(条件类型、infer、映射、模板串)
         —— 相当于学了"加减乘除"
第 3 周 = 练习"组合与直觉"(在真实题目里组合机制)
         —— 相当于做"应用题"

你已经拥有全部武器,缺的只是出鞘的手感。

二、训练场:type-challenges 全解

2.1 题库介绍

// 每道题的文件结构:
// questions/00014-easy-first/index.ts

// ================ 题目描述(中文注释)================
// 给出一个数组,返回其第一个元素的类型
// 例如:First<[3, 2, 1]> → 3

// ================ 你的答案写在这里 ==================
type First<T extends any[]> = /* 你的实现 */ any;

// ================ 测试用例 ==========================
type cases = [
  Expect<Equal<First<[3, 2, 1]>, 3>>,
  Expect<Equal<First<[() => 123, { a: string }]>, () => 123>>,
  Expect<Equal<First<[]>, never>>,
  Expect<Equal<First<[undefined]>, undefined>>
];

2.2 两种做题环境(选其一)

方案 A:克隆仓库(正式方案,推荐)

git clone https://github.com/type-challenges/type-challenges.git
cd type-challenges
code .
# 用 VSCode 打开,直接改 questions/ 里的题目文件
# 文件无红色报错 = 全部用例通过

方案 B:Playground 轻量方案(零配置)

// 在 https://www.typescriptlang.org/play 里
// 先粘贴这段"判题基建",再贴题目用例:

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

/** 期望断言:参数必须是 true,否则爆红 */
type Expect<T extends true> = T;

// 用例写法:
type _ = Expect<Equal<你的答案<输入>, 期望输出>>;

2.3 本周做题的约定

铁律 1:每题先独立写 15 分钟,卡住再看提示(只看第一行)
铁律 2:看完答案的题,第二天必须默写重做
铁律 3:每题过关的标准 = 用例全绿 + 能讲出每一行的作用
铁律 4:不追求数量——1 题吃透 > 5 题囫囵

三、判题机制:Equal 与 Expect

3.1 为什么需要 Equal(不能直接比较)

// 疑问:TS 里为什么不能写 First<[3,2,1]> === 3 ?
// 因为类型没有 === 运算符——比较类型只能靠 extends:

type A = "a" extends "a" ? true : false;   // true

// 但 extends 判等有大坑:
type B = any extends string ? true : false;   // true ⚠(any 双向匹配)
type C = string extends any ? true : false;   // 也 true ⚠
// any 和 string "互相相等"?!—— 分配律 + any 的特殊规则

// 联合还会被分配拆开:
type D = (string | number) extends string ? true : false;   // boolean ⚠

3.2 官方 Equal 的原理(看懂即可,不必能写)

type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends
  (<T>() => T extends Y ? 1 : 2) ? true : false;
拆解(只需理解到这个深度):
1. (<T>() => T extends X ? 1 : 2) 构造了两个"泛型函数类型"
2. TS 比较两个泛型函数时,用的是"内部同一性"(internal identity)
   —— 比 extends 更严格的逐位比较
3. any 在这里不会双向匹配、联合也不会被分配
4. 结果:真正意义上的"类型完全相等"

使用层面的结论:判题用 Equal,业务里判"是不是同类型"也用它(第 2 周 Day 12 的 IsEqual 同款)。

3.3 Expect:失败即爆红

type Expect<T extends true> = T;

// 原理:泛型约束 T extends true
// 传入 true  → 满足约束,不报错
// 传入 false → 违反约束,编译错误(编辑器爆红)

type _1 = Expect<Equal<First<[3, 2, 1]>, 3>>;   // ✅ 安静
type _2 = Expect<Equal<First<[3, 2, 1]>, 2>>;   // ❌ 爆红

四、体操方法论:读题三板斧

这是本周最重要的产出——拿到任何题,按顺序问三个问题

第一问:输入输出的"形状"差在哪?

// 示例题:TupleToUnion<[1, 2, 3]> → 1 | 2 | 3
// 形状分析:
//   输入:元组(3 个成员)
//   输出:联合(3 个成员)
//   本质:元组 → 联合 的"容器变换",成员本身不变
// → 方向:索引访问 T[number](Day 16 会正式讲)

// 示例题:Trim<"  hi  "> → "hi"
// 形状分析:
//   输入:字符串字面量(含空格)
//   输出:同字符串去掉两端空格
//   本质:字符串的"裁剪"
// → 方向:模板字面量 + infer + 递归(Day 18)

先说清变换的本质,再动手——90% 的"没思路"是没做这一步。

第二问:需要"拆开"吗?用什么拆?

四大拆解武器(对应四种数据形状):

拆联合 → 分配律(裸类型参数 + 条件类型)
         T extends U ? ... : ...(自动逐成员)

拆元组 → infer + 变长位置
         [infer F, ...any[]]、[any, ...infer R]、[...any[], infer L]

拆字符串 → 模板字面量 + infer
         `${infer Head}${infer Tail}`、`${infer D}:${string}`

拆对象 → keyof + 映射 / 索引访问
         [K in keyof T]、T[K]、as 重映射

第三问:需要"递归"吗?

// 判断标准:输出规模是否与输入规模"联动"
// - Trim 一个空格 → 不用递归(一发条件类型)
// - Trim 任意多个空格 → 递归(每轮啃一个)
// - Replace 第一处 → 不递归
// - ReplaceAll → 递归
// - Readonly 一层 → 不递归
// - DeepReadonly → 递归(深入每层)

// 递归必查两件事:
// 1. 终止条件是什么?(什么形状走到 else 分支)
// 2. 每轮缩小了什么规模?(剥了几层皮/啃了几个字符)

三板斧实操示范

// 拿一道新题走一遍完整流程:
// 题:Flatten<[1, [2]]> → [1, 2](元组打平)

// 第一问:形状差在哪?
//   输入:嵌套元组;输出:打平元组。成员不变,层级变 1。

// 第二问:要拆吗?
//   要——元组拆解。[infer F, ...infer R] 拆头和尾。

// 第三问:要递归吗?
//   要——嵌套深度未知。F 本身可能是数组 → 对 F 也要打平(递归)。

// 顺着答案写:
type Flatten<T extends readonly any[]> =
  T extends [infer F, ...infer R]
    ? F extends readonly any[]
      ? [...Flatten<F>, ...Flatten<R>]   // 头也是数组 → 打平头 + 打平尾
      : [F, ...Flatten<R>]               // 头不是数组 → 头直接拼 + 打平尾
    : [];                                 // 空元组 → 终止
// (这题是 Day 17 的正式题目,今天只需感受方法论的推导路径)

五、热身赛:四道题全解

5.1 第 13 题 · Hello World(warm)

// 题目:期待类型是 string
type HelloWorld = string;

type _ = Expect<Equal<HelloWorld, string>>;
// 纯开营仪式——但它是你在这 4 万 star 仓库里的第一个 ✅

5.2 第 4 题 · Pick(easy · 默写验收)

// 题目:实现内置 Pick<T, K> 的类型版本
// Pick<{ title: string; done: boolean }, "done"> → { done: boolean }

// 先默写(第 2 周 Day 13 手写过 MyPick):
type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};

// 用例验证:
type Todo = { title: string; done: boolean };
type TodoPreview = MyPick<Todo, "done">;
type _ = Expect<Equal<TodoPreview, { done: boolean }>>;

验收点:如果你 3 分钟内没写出,回看 Day 13 第六节——本周后六天全部建立在这个基础之上。

5.3 第 7 题 · Readonly(easy · 带一个新知识点)

// 题目:实现内置 Readonly<T>,注意本题的签名:
interface Todo {
  title: string;
  description: string;
}
function readonly(todo: Todo): Readonly<Todo> { /* ... */ }

// 你的实现:
type MyReadonly<T, K extends keyof T = keyof T> = {
  readonly [P in K]: T[P];
};
// ⭐ 本题新知识点:K extends keyof T = keyof T(默认类型参数)
//   签名允许 MyReadonly<Todo, "title">(部分只读)
//   不传 K 时默认全量——比内置 Readonly 多了个"超能力"

5.4 第 189 题 · Awaited(medium · 信心题)

// 题目:实现 Awaited<T>
//   Awaited<Promise<string>>          → string
//   Awaited<Promise<Promise<string>>> → string ⭐(嵌套也要解!)
//   Awaited<string>                   → string(非 Promise 原样返回)

// 你的实现(第 2 周 Day 12 的 MyAwaited 直接迁移):
type MyAwaited<T extends Promise<any> | any> =
  T extends Promise<infer V>
    ? MyAwaited<V>       // 是 Promise → 解一层,对内部递归
    : T;                 // 不是 → 终止返回

// 验证:
type _1 = Expect<Equal<MyAwaited<Promise<string>>, string>>;
type _2 = Expect<Equal<MyAwaited<Promise<Promise<string>>>, string>>;
type _3 = Expect<Equal<MyAwaited<string>, string>>;

成就感时刻:这是道 medium 题——开营第一天就过了 medium,因为你在第 2 周已经悄悄练过了。这就是系统学习的复利。

5.5 热身赛复盘模板(照抄进笔记)

日期:____
题目:____
第一问(形状):输入__ → 输出__,本质是__变换
第二问(拆法):用了__武器(分配律/infer 位置/模板串/映射)
第三问(递归):需要/不需要,因为__
卡壳点:____(没有就写"无")
一句话总结这题的"套路":____

六、本周武器库总览

今天先"阅兵"——知道有哪些武器,后面六天逐个实弹操练:

// ===== 已掌握(第 2 周学过,本周直接用)=====
// 1. 条件类型 T extends U ? X : Y
// 2. 分配律(裸参数拆联合)与 [T] 阻止分配
// 3. never 吸收(联合过滤)
// 4. infer 模式捕获(数组/Promise/函数/元组/字符串)
// 5. 映射类型 [K in keyof T] + as 重映射
// 6. 模板字面量类型 + 内置字符串工具

// ===== 本周新增(预告)=====
// 7. 变长元组:[...T, ...U] 拼接、[infer F, ...any[]] 头尾拆分(Day 16)
// 8. 元组同态映射:映射类型作用在元组 = 逐元素变换(Day 17)
// 9. T[number] / T["length"] 索引魔法(Day 16)
// 10. 联合模式匹配:S extends ` ${infer R}` | `${infer R} `(Day 18)
// 11. 链式累积:交叉类型 & 的递归累积(Day 19)
// 12. 变型理论:协变/逆变/不变 + in/out 注解(Day 20)

七、类比记忆:健身房 vs 算法题

type-challenges = 健身房
┌────────────────────────────────────────┐
│  哑铃(easy)   :动作标准 > 重量        │
│  杠铃(medium) :本周的主训练重量        │
│  大重量(hard) :辅助保护下试举(Permutation)│
│                                        │
│  判题基建(Equal/Expect)= 私教的秒表    │
│  读题三板斧 = 热身流程(不热身必受伤)    │
│  15 分钟规则 = 力竭才找人保护            │
│  第二天默写 = 同一动作隔天重复训练        │
└────────────────────────────────────────┘

刷类型题 ≈ 刷算法题
┌────────────────────────────────────────┐
│  看题解觉得"就这?" → 合上写不出来 = 没学会│
│  唯一诚实的指标:隔天默写的通过率          │
└────────────────────────────────────────┘

八、常见坑点与最佳实践

坑点 1:直接抄答案获得"通过"

// 看一眼答案,"哦这么简单",复制粘贴,用例全绿
// ——这是最危险的自欺:第二天默写必然失败
// 落实"铁律 2":今天抄的每道题,都是明天的默写作业

坑点 2:把 extends 当等号用

// 判题必须 Equal(第 3 节讲过),日常自查也一样:
type X = MyAwaited<Promise<string>>;       // 悬停看 "string"
type _ = Expect<Equal<X, string>>;          // ✅ 断言确认

// 别用 extends 判等(any/联合的坑)
type Bad = Expect<X extends string ? true : false>;   // ⚠ 不可靠

坑点 3:不看测试用例就动手

// 用例里藏着边界条件——先读完所有用例再写:
// Expect<Equal<First<[]>, never>>     ← 空元组的边界!
// Expect<Equal<First<[undefined]>, undefined>>   ← undefined 也是合法成员!

// 漏掉边界的实现 = 用例爆红 = 返工
// 读用例本身就是"审题"的一部分

坑点 4:Playground 里忘了贴判题基建

// Equal/Expect 没定义就直接用 → 全屏爆红
// 建议:存一个 Playground 的"模板链接",
// 或本地建一个 template.ts 随用随拷

最佳实践清单

  1. 先读用例再写实现——边界条件全在用例里

  2. 悬停验证中间态:写一半就悬停看类型长什么样,别憋大招

  3. 复盘模板每题必填——这是你本周最宝贵的资产

  4. 卡壳超 15 分钟才看提示,且只看第一行

  5. GitHub 提交记录(可选):commit message 写题目号,未来简历可引用


九、自测挑战

Q1:类型体操的定义是什么?它和"写运行时代码"的本质区别?

Q2:为什么判题不能直接用 extends 判等?Equal 靠什么机制实现严格相等?

Q3Expect<T extends true> 为什么传入 false 会爆红?

Q4:读题三板斧是哪三问?每问分别在排查什么?

Q5:判断以下题目分别需要什么"拆解武器"(不看后文):

type A = Exclude<"a" | "b" | "c", "b">;        // 拆 ___
type B = [string, number] → number;             // 拆 ___
type C = "device:update" → "device";            // 拆 ___
type D = { a: 1; b: 2 } → { a: 1 };             // 拆 ___

Q6:以下哪些变换需要递归?为什么?

// A. 去掉字符串两侧一个空格
// B. 去掉字符串两侧所有空格
// C. 对象第一层变只读
// D. 对象所有层级变只读

Q7:默写今天的四道热身题(不看文档)。

Q8MyReadonly<T, K extends keyof T = keyof T> 的默认参数带来什么便利?


十、总结与知识图谱

类型体操入门(Day 15)
│
├── 认知
│   ├── 定义:编译期纯类型层的"计算"
│   ├── 动机:读库源码 / 真实业务 / 面试
│   └── 与第 2 周关系:机制 → 组合(语法 → 应用题)
│
├── 训练场
│   ├── type-challenges 仓库结构
│   ├── 双环境:克隆仓库 / Playground + 判题基建
│   └── 四条铁律(15 分钟 / 隔天默写 / 讲得出 / 吃透)
│
├── 判题机制
│   ├── Equal(内部同一性比较,any/联合安全)
│   ├── Expect<T extends true>(false 即爆红)
│   └── 为什么 extends 判等不可靠
│
├── 方法论(本周核心资产)
│   ├── 第一问:形状差在哪(先说本质)
│   ├── 第二问:怎么拆(四大武器对应四种形状)
│   └── 第三问:要不要递归(规模联动 + 终止条件)
│
├── 热身赛(4 题)
│   ├── 13 Hello World(开营仪式)
│   ├── 4 Pick(默写验收)
│   ├── 7 Readonly(默认类型参数新知)
│   └── 189 Awaited(medium 信心题)
│
└── 复盘模板(每题必填)
    形状 / 拆法 / 递归 / 卡壳点 / 套路总结

一句话总结:今天没学新语法——学的是"赛场规则"(Equal/Expect)和"赛前流程"(读题三板斧)。工具箱已就位,明天开始逐个实弹操练。


延伸阅读

资源

说明

type-challenges 仓库

本周主战场(README 有中文指南)

TypeScript Playground

轻量做题环境

Equal 的实现讨论

官方 issue 里的判等技巧溯源


下一步

明天(Day 16)进入元组体操:变长元组、推断位置、T[number]/T["length"] 索引魔法——一口气拿下 7 道 easy,组建你的"元组五虎将"工具箱。


开营第一天就过了 medium(Awaited)——不是你今天变强了,是第 2 周的你早就为此做好了准备。

每天花 2 小时,28 天通关 TypeScript 深入。第 3 周,正式开赛!


评论