
如果你写 TypeScript 超过一年大概率刷到过 Matt Pocock 这个名字。我第一次点开他的 Type Challenges 页面时第一反应是“原来 TS 还能这么玩”——那不是把 JavaScript 加上类型标注而是像写业务代码一样写类型本身用一套完整的、可练习的体系去训练类型思维。后来我完整跟完 Total TypeScript 的课程和挑战才意识到大家常说的“Matt Pocock Skills”并不是某个单一技巧而是一整套覆盖泛型、条件类型、infer、模板字面量类型、递归类型并且最终落到真实项目里的能力框架。这篇内容我打算把“这套技能到底是什么、怎么练、怎么用进项目、以及哪些坑不值得踩”完整梳理一遍。它适合那些已经能正常写 React/Vue TS 业务代码但遇到infer和复杂的extends就心里发怵的开发者也适合想给团队建立一套类型规范但不知从何入手的技术负责人参考。1. 先搞清楚Matt Pocock 这套“技能”到底包含什么很多人误以为 Matt Pocock 只是一个做 TypeScript 教程的网红博主。实际接触下来会发现他经营的是一个相当完整的学习生态市面上绝大多数 TS 教学内容都是零散的“知识点”而他这套体系的核心是把“类型能力”拆成了可训练、可量化、可进阶的模块。1.1 一个内容生态不是一个教程Matt Pocock 的内容至少包含四块每一块的定位都不一样Total TypeScript 付费课程系统讲解从基础到进阶的类型知识重点覆盖泛型、条件类型、infer、模板字面量类型等高阶能力配合大量可运行示例。免费的 Type Challenges一个面向类型系统的“题库”类似前端界的 LeetCode但练的对象是类型推导、类型约束和类型工具实现。每周 TypeScript Tips 邮件每周一个短小精悍的类型技巧很多邮件里的例子直接能用在工作里。YouTube 和 Twitter/X 上的实战拆解经常拿真实开源项目的类型定义做分析比如如何给fetch封装类型安全层如何用satisfies保留字面量类型。除了内容形态他更核心的教学理念是“先猜结果再对答案”。在 Type Challenges 里你会看到一个题目的类型定义然后需要自己推断最终结果是什么猜错了也没关系编译器的报错信息本身就是一种教学材料。这种模式和我平时看文档式的学习完全不同——文档是“别人给你讲”挑战是“你自己试错”后者留下的记忆深度完全是两个级别。1.2 为什么“猜类型结果”式学习有奇效用一个类比来解释学开车如果只坐在副驾看教练操作哪怕看一百遍你也不会形成肌肉记忆。但只要你亲自握方向盘哪怕开得歪歪扭扭也会立刻记住“方向盘打多了会是什么感觉”。Type Challenges 的“猜结果”机制本质上就是这个原理。它逼着你在没有任何提示的情况下根据已有的类型知识去推演结果。推演正确说明你的心智模型是对的推演错误编译器的报错会告诉你具体哪一步和预期不符。而且 TypeScript 的报错信息在近几个版本里已经做得非常精确。比如当你把一个string类型传给一个期望number类型的位置报错会直接指出类型不兼容的原因甚至给出“你可能想用Number()转换”之类的建议。这套机制配合题目本身的渐进设计让初学者也能在“错误-修正-再错误-再修正”的循环中建立正确的类型直觉。1.3 适合谁不适合谁我见过一些刚学 JS 两三个月的人一上来就刷 Type Challenges结果被infer直接劝退。这不是毅力问题是基础问题。Matt 这套体系的目标人群很明确你已经能独立写业务代码知道interface、type、泛型函数的基本用法只是遇到复杂类型时无法更好地抽象和设计。如果你是以下几种情况我建议先别急着学不理解extends关键字在泛型约束里的作用从来没开过 tsconfig 的strict模式还经常用any跳过报错。反过来如果你已经写过一年以上 TS能解释PartialT、PickT, K这类内置工具类型的实现原理那这套体系就是为你准备的。2. 底层地基这套技能里的四个高频类型工具Matt 的课程和挑战虽然题量很大但拆开看核心工具并不多。我觉得最值得深入理解的是四个泛型、条件类型 infer、模板字面量类型、递归类型。这四个就像是类型世界的“语法基础”后续所有花哨的类型工具都是它们的组合。2.1 泛型把“类型逻辑”写成函数泛型的本质是“类型层面的函数参数”。在普通函数里你写function add(a, b)a和b是值的参数在泛型里你写T(arg: T): TT是类型的参数。值参数在运行时传入具体值类型参数在编译期被推导为具体类型。很多人的误区是泛型等于any。这是完全错误的。any是“放弃类型检查”T是“保留类型之间的关系”。举例function identityT(arg: T): T { return arg; } const a identity(hello); // a 的类型是 hello字面量类型而不是 any const b identity(42); // b 的类型是 42而不是 any这里最有价值的是“类型之间的关系”传入string就返回string传入number就返回number。如果是any那你拿到的返回值就彻底失去类型信息了。Matt 的教学里还有一个非常重要的进阶用法const泛型参数。普通的泛型会把[a, b]推导成string[]但加上const之后会保留数组每个元素的具体字面量类型function tupleT extends readonly unknown[](args: T): T { return args; } const t1 tuple([a, b]); // 类型是 [a, b] const t2 tuple(a, 42, true); // 类型是 [a, 42, true]没有constt1的类型是string[]有了const它才是真正的元组。这个特性在写表格组件、CSV 解析器、路由配置等场景里非常关键。2.2 条件类型与 infer类型系统里的 if/else 和模式匹配条件类型的语法是T extends U ? X : Y读作“如果 T 可以赋值给 U那么结果是 X否则结果是 Y”。它是类型系统里的 if/else。但仅仅有extends还不够真正让条件类型强大的是infer关键字。infer的意思是“在这个匹配过程中把某一部分类型提取出来”。你可以把infer理解成类型层面的“解构赋值”它让类型系统具备了模式匹配能力。一个最经典的例子是 TypeScript 内置工具类型ReturnTypeT的实现type MyReturnTypeT extends (...args: any) any T extends (...args: any) infer R ? R : never; type Fn (x: number, y: string) boolean; type Result MyReturnTypeFn; // boolean这段类型读起来是这样的T必须是一个函数类型如果T能匹配(...args: any) infer R就把 R 提取出来作为函数返回值。infer R相当于在匹配过程中“开了一个洞”让 TypeScript 编译器自己算出来这个洞应该填什么类型。我在实践里的体会是条件类型 infer 是一个分水岭。不会用 infer 之前你只能“调用”别人写好的工具类型学会 infer 之后你就能“创造”自己的工具类型。比如想提取 Promise 的返回值类型手写一个AwaitedT就是 infer 的绝佳应用。2.3 模板字面量类型类型也能做字符串的匹配模板字面量类型Template Literal Types是 TS 4.1 引入的特性它允许你在类型层面做字符串的拼接和拆分。乍一听可能有点抽象但它在路由、表单、枚举校验等场景里威力巨大。比如你要设计一个拼接好的路由路径类型type RouterPath /user/${string}/post/${number}; const validPath: RouterPath /user/alice/post/42; // 正确 const invalidPath: RouterPath /user/alice/post/abc; // 报错number 位置不能是 string这比普通的string类型严格得多它在编译期就保证了你不会写出格式错误的路由字符串。更进阶的用法是配合 infer 做“拆分”。比如从一个路径字符串里提取用户 IDtype ExtractUserIdT extends string T extends /user/${infer Id}/post/${number} ? Id : never; type Id1 ExtractUserId/user/123/post/456; // 123 type Id2 ExtractUserId/user/alice/post/456; // alice这种写法在分析路由参数、解析命令行的 tutorial 里很常见。Matt 的挑战中有大量基于模板字面量类型的题目练完你会明显感觉到对字符串类数据的约束能力提升了一个档次。2.4 递归类型类型层的循环在类型系统里没有for循环但可以通过递归类型实现类似效果。比如想把一个嵌套多层的对象所有属性都变成只读type DeepReadonlyT { readonly [K in keyof T]: T[K] extends object ? DeepReadonlyT[K] : T[K]; }; interface User { name: string; address: { city: string; zip: string; }; } type RO DeepReadonlyUser; // address 内部的属性也全部变成 readonly递归类型的核心是“终止条件”。我在学的时候最容易犯的错是忘记设置终止条件结果类型层无限递归编译器直接报“Type instantiation is excessively deep and possibly infinite”。TypeScript 在 4.5 之后对递归类型做了尾部递归优化即使更深的递归也不会轻易报错。但作为使用者我仍然建议递归深度超过 3 层就要思考是否设计得过于复杂。递归类型适合处理树形结构、嵌套菜单、JSON 对象等但它不应该成为你日常写类型的第一选择。3. 把技能落到项目三个真实可抄的场景学类型技能最怕“学了一堆工具但项目里用不上”。我自己的经验是至少找三个真实场景把学会的类型工具用进去才算真正掌握。这里分享三个我实际在项目里用过的模式都来自 Matt 教学体系的延伸。3.1 给 fetch 包一层类型安全裸fetch的问题很明显response.json()返回的是any这意味着整个链路的类型检查都是失效的。很多人会选择as IUser强转但这样一旦接口变更运行时错误要到浏览器里才能发现。更好的方式是写一个泛型封装async function fetchJsonT(url: string, init?: RequestInit): PromiseT { const response await fetch(url, init); if (!response.ok) { throw new Error(HTTP ${response.status} - ${response.statusText}); } return response.json() as PromiseT; } interface User { id: number; name: string; } async function loadUser(id: number) { const user await fetchJsonUser(/api/users/${id}); return user.name; // 类型安全 }这个封装的核心价值在fetchJsonT的泛型参数调用时传入User编译器就知道返回值是PromiseUser。当然有人会说“我还是用了as强转啊”。这里要说明一点as用在“外部边界”是可以接受的关键是不要让any在业务代码里四处扩散。把防御边界收口在数据进入点比到处写断言要安全得多。更进一步配合 Zod 做运行时校验import { z } from zod; const UserSchema z.object({ id: z.number(), name: z.string(), }); type User z.infertypeof UserSchema; async function fetchAndValidate(url: string, schema: z.ZodTypeany) { const data await fetch(url).then((r) r.json()); return schema.parse(data); }这里的z.infertypeof UserSchema是 Matt 生态里经常提到的关键组合z.infer能从 Zod schema 中自动提取出对应的 TS 类型保证“运行时校验规则”和“编译期类型”永远保持一致不会出现一处改动另一处忘改的经典问题。3.2 用 satisfies 保留最精确的字面量类型as const是给对象字面量添加只读和字面量类型的手段。但它有个问题如果你直接给对象标注一个宽泛接口类型字面量类型会被拓宽掉如果你完全用as const又会失去对接口结构的校验。satisfies运算符就是来解决这个两难问题的。type RouteConfig { path: string; children?: RouteConfig[]; }; const routes { home: { path: /home }, user: { path: /user/:id, children: { profile: { path: /user/:id/profile }, }, }, } satisfies RouteConfig; type HomePath typeof routes.home.path; // 类型是 /home而不是 string好处是satisfies既像:一样做结构校验又像as const一样保留每个属性的具体字面量类型。这在配置驱动型项目里省了我大量麻烦——路由配置、表单配置、权限配置都适用。3.3 事件发射器让 emit 和 on 的签名自动匹配很多同学写事件回调时on和emit的参数是分开维护的很容易出现“emit 传两个参数on 只接收一个”的错误。这种错误只有运行时才会暴露。用泛型和映射类型可以把事件名和参数签名绑定在一起。type EventMap { userLogin: (userId: number) void; pageView: (page: string) void; }; class TypedEmitterEvents extends Recordstring, (...args: any[]) void { private listeners: { [K in keyof Events]?: Events[K][]; } {}; onK extends keyof Events(event: K, listener: Events[K]): void { if (!this.listeners[event]) this.listeners[event] []; this.listeners[event]!.push(listener); } emitK extends keyof Events(event: K, ...args: ParametersEvents[K]): void { this.listeners[event]?.forEach((listener) listener(...args)); } } const emitter new TypedEmitterEventMap(); emitter.on(userLogin, (id) console.log(id.toFixed(2))); // id 自动推导为 number emitter.emit(userLogin, 42); // 正确 emitter.emit(userLogin, abc); // 报错string 不能赋值给 number这里的核心是ParametersEvents[K]Events[K]是事件对应的函数类型Parameters提取该函数的所有参数类型。这样一来emit的参数就会自动跟随on注册的回调签名编译器就是你的跑腿员。3.4 没有类型声明的第三方库也能做最小声明有些老库没有提供类型定义常规做法是declare module xxx然后全部用any。但用学会的类型技能你可以写一个最小但安全的声明。比如一个简单的格式化函数declare module old-format-lib { export function formatT extends string(input: T, options?: { uppercase?: boolean; }): T extends date ? string : number; }调用时根据传入的字符串字面量返回值类型会自动变化。这虽然不如官方类型定义全面但比无脑any强太多了。4. 照着这套方法练我排过的学习和练习路线很多人收藏了一堆教程却一直没开始或者开始后没几天就放弃了。我把自己跑通的路线整理出来尽量具体到“每天做什么”。4.1 从免费的 Type Challenges 入门Type Challenges 的入口地址很好记GitHub 上搜索type-challenges就能找到。页面会把题目按难度分为 warm-up、easy、medium、hard 和 extreme 几档。我的建议是从 easy 开始但不要跳过 warm-up。哪怕你现在觉得自己基础还可以也先用 3 天时间把 warm-up 和 easy 各做 5 题以上目的是熟悉题目的交互方式。每天练习流程打开一道题先看类型定义不写任何代码口头推理一遍最终类型应该是什么。打开编辑器自己尝试实现。跑tsc或直接看编辑器里的类型提示对比你的预期。无论做对做错都看一眼官方解答里的type-challenges社区讨论理解不同的解法思路。我坚持了大概两个月每天 1-2 题。第一周很痛苦几乎每题都要看答案第三周开始easy 难度基本能独立完成第二个月medium 的正确率能达到一半以上。这个过程没有捷径。4.2 把课程当词典把挑战当健身房如果只做题不看课程容易在做 hard 题时卡住很久。如果只看课程不做题绝对学不会。我自己的配合方式是课程视频主要看“某一类工具的引入动机”比如为什么要引入infer、模板字面量类型解决了什么问题挑战题目负责“高强度训练”把课程里学的工具用到具体问题里遇到不会的先翻 TypeScript 官方文档对应章节再看 Matt 的解题视频最后才看社区讨论。配套工具方面建议把这几样配齐tsconfig 开启strict: true使用最新稳定版 TypeScript本地安装type-test或vitest的类型检查模式把类型断言写成单测方便回归在 VS Code 里把editor.largeFileOptimizations关掉不然某些大型类型文件可能提示不准编辑器会变慢。4.3 卡壳时的三个自救思路Type Challenges 有时候一道硬题会卡两三天那种挫败感很真实。我总结出三个自救办法看报错信息的前三行。TS 报错信息虽然不是百分百直观但前三行通常指出了具体类型的位置比瞎猜强。把复杂类型拆小。比如你想实现DeepReadonly先实现ReadonlyT再处理对象再处理嵌套。每一步都单独建一个中间类型去验证。保留每一次失败解法。我做过一个实验每次写错解法后在仓库里git commit一下过几天再回看“当时为什么错”比看正确答案更能加深记忆。5. 我实践里的“反模式”清单类型技能不是越强越好练了一段时间之后我一度沉迷于把类型写得“高深莫测”后来在真实项目里踩了不少坑。这里列几个值得警惕的反模式。5.1 为炫技而写的类型如果同事看一个类型定义超过三分钟还没看懂这个类型大概率不应该出现在业务代码里。类型是给人看的不只是给编译器看的。一套好的类型设计应该“看起来像普通的接口用起来足够安全”而不是一堆流水线式的条件类型嵌套。我现在的团队约定是业务代码里不允许出现手写的infer所有工具类型统一放到types/utils.ts并且必须带注释说明用法和边界必要的话配上类型测试。5.2 any、unknown、never 的区别与选择这三个类型初学者最容易混。我的选择优先级是尽量不用any优先unknown必要时使用never做约束。any是“忽略检查”不推荐因为它会污染整个调用链unknown是“还有待收窄的值”可以用类型守卫或条件类型收窄never是“不可能出现的值”常用于失效分支或穷举检查。一个典型场景如果需要从某个动态对象取值先标成unknown再写一个类型守卫function isUser(value: unknown): value is User { return ( typeof value object value ! null name in value id in value ); }用类型守卫把unknown收窄成具体类型整个过程可控安全。5.3 团队协作里的类型治理类型不仅是个人能力还是一种团队协作规范。我建议在工程里加入这些检查CI 里跑tsc --noEmit禁止跳过对核心 API 层写类型测试防止回归PR Review 时坚持“类型复杂度可解释”。如果作者写了一个复杂的条件类型要在 PR 描述里解释它解决什么问题对公共包的类型定义加上tsd或vitest typecheck类型的断言。5.4 从“会写类型”到“会设计类型”最后一个阶段是思维转变。练了半年之后我发现最大的变化并不是类型写得花哨而是写业务代码之前会先想清楚边界——这个值到底是什么可能是什么不该是什么Matt 的教学里反复出现一种“约束边界”的思维不要先写实现再随手补类型而是先定义类型的边界再实现业务逻辑。比如封装一个定时器先想清楚“回调函数接收什么参数、返回什么、能不能重复注册”先定义类型签名再写实现。这种“类型先行”的做事方式能显著减少运行时错误也让代码更容易阅读和测试。我在实际使用中最深的一个体会是类型技能的价值不在于让你写出复杂的类型而在于让你在写代码前学会提问。如果你也正在学 Matt Pocock 这套体系别急着挑战 extreme 难度的题目先把 easy 和 medium 吃透在真实项目里选一个小工具比如 fetch 封装、事件发射器、路由配置用上那一两个类型工具一个月后再回头看你会感谢现在的自己。