如果你近两年刷过 TypeScript 相关的视频或者推文,Matt Pocock Skills 这几个字很难完全避开。Matt Pocock 是英国一位专注 TypeScript 教育的工程师,他做的 Total TypeScript 几乎成了很多人进阶类型系统的首选读物。我经常看到有人把他的教程加入书单,然后停留在“看过”的状态。这篇东西的起因很简单:我用自己的方式把他常用的几项技能拆出来,在真实项目里逐个试了一遍,踩了不少坑,也确确实实把类型水平拉高了一截。
先把丑话说在前面:Matt Pocock Skills 并不是一个可以一键安装的插件,也不是某个能 Ctrl+S 保存进大脑的捷径。它更像一套工作方法,由几个具体的能力点串起来。这篇文章不打算复述他的课程,也不做字幕式解读,而是告诉你这些技能在什么场景下能救命、怎么写才不翻车,以及我实践过程中到底踩了哪些坑。
1. 先说清楚:它到底算硬技能还是软技能
很多人看到 “Skills” 这个词,第一反应是去找仓库,但真正值得研究的是他处理问题的底层套路。
1.1 从“看视频”变成“有技能”的关键节点
我看 Matt 的内容大概有两年,前期最大的问题是:视频里的每个类型都能看懂,但自己动手写业务代码时完全想不起来用。后来我发现,他所有教学内容其实都在反复强调同一个点——类型应该为你的业务服务,而不是为类型本身服务。
也就是说,从他的视角看,泛型、条件类型、infer、递归类型只是工具,真正的技能是判断“这个业务场景需不需要用这些工具”。大多数教程会先讲语法,再给例子,他则是先给一个非常具体的错误场景,然后让你看类型系统是怎么一步步收紧约束的。这个顺序差别决定了你看完之后是“会做题”还是“会做事”。
1.2 为什么把它单独拎出来聊“使用指南”
因为网上的内容有两种极端:一种是给新手看的基础,停留在interface和type;另一种是类型体操炫技,天天写柯里化类型。Matt 的内容刚好卡在中间——既有业务价值,又有一定难度。
我给这篇文章定下的范围是:
- 拆出他内容里最高频出现的几个技能点;
- 每个技能点给一个真实项目里的最小场景;
- 记录我自己用错的例子和改正过程。
这样读完你至少能带走一套可以立刻复用的“决策流程”,而不是收藏一堆代码片段。
2. 技能一:把运行期错误提前到类型期
这是我认为最值得学的一点,也是 Matt 所有内容的地基。他总说类型系统不是用来折磨你的,而是让你在代码运行之前就发现问题的安全网。
2.1 别再让any偷走你的安全网
我见过太多业务代码里出现这种写法:
const response = await fetch('/api/user') const data: any = await response.json()一旦data变成any,后面所有字段访问都不会有提示。用户名字段拼错了、接口字段改了,这些错误全部留到运行时才暴露。Matt 的视频里几乎不会出现这种写法,他会明确告诉你:用any等于手动关掉了安全网。
正确的思路不是“把类型写对”,而是“让错误可以被类型发现”。比如把接口响应定义清楚:
type UserResponse = { id: string name: string email: string } const response = await fetch('/api/user') const data = await response.json() as UserResponse这里用as断言虽然还有点上头,但比any好太多。如果再配合运行时校验(比如 Zod),那才算真正闭环。
2.2 用可辨识联合消灭“魔法状态”
我记得他有一个很经典的例子:处理下单状态。很多新手会写成三个布尔值:
type OrderState = { isLoading: boolean isError: boolean isSuccess: boolean }问题在于这种结构能拼出无数种非法状态:isLoading和isError同时为true。类型系统完全管不住。Matt 的做法是用可辨识联合(Discriminated Union)——用一个共同的status字段把状态锁死:
type OrderState = | { status: 'loading' } | { status: 'error'; error: Error } | { status: 'success'; data: OrderData }这样当你判断state.status时,TypeScript 会自动为你收窄类型。在success分支里能拿到data,在error分支里能拿到error。设计类型时问自己“这个状态如果写错了,编译器能拦住我吗”,这就是他说的方法论。
我自己实践时发现,刚开始会不习惯,总想给每个状态都塞一个公共字段。但跑过一个月之后,整个项目里异常状态明显少了,因为代码根本编译不过去。
3. 技能二:泛型是参数间的契约,不是炫技道具
泛型大概是 Matt 内容里出现频率最高的词。但他和很多教程最大的区别是:他几乎不强调泛型的“高级感”,而是强调泛型能替你建立输入和输出之间的关系。
3.1 同一个函数,多个参数类型如何联动
先看一个很常见的反例:
function getProperty(obj: any, key: string): any { return obj[key] }这种函数用起来和没有类型一样。换成泛型之后就不一样了:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key] }这里的核心不是any变成了T,而是key和obj、返回值建立了约束关系。K extends keyof T保证了key必须是对象里真实存在的键,返回值T[K]保证了类型和属性值严格对应。
我最早学泛型的时候,光记语法了,根本没搞懂为什么要写两个类型参数。直到有一天我需要封装一个事件总线,才真正体会到这里面的威力:
type EventMap = { click: { x: number; y: number } input: { value: string } } function on<EventName extends keyof EventMap>( event: EventName, handler: (payload: EventMap[EventName]) => void ): void { // implementation }现在调用on('click', (payload) => ...)时,payload会自动推断为{ x: number; y: number }。不需要任何手动的as,也不会出现类型错位。
3.2 泛型约束别乱写,先看看有没有必要
Matt 有一条非常反直觉的建议:不要为了泛型而泛型。如果你的函数只处理一个固定类型,那就老老实实写死;如果所有参数之间没有关联,加泛型可能只是增加阅读负担。
举个例子,新手容易写出这样的函数:
function identity<T>(value: T): T { return value }如果只是做断言或者中转,这种确实没问题。但在业务代码里,如果一个接口返回始终是User,直接写function getCurrentUser(): User就够了。硬要加个T,反而让后面的人没法立刻判断返回类型。
判断标准很简单:如果泛型参数只出现一次,那多半是用错了地方。真正的泛型一定会在多个位置出现,比如参数里的T、返回值里的T或者类型计算里的T。只出现一次说明它和输入输出没有建立关系,那就没有必要存在。
4. 技能三:条件类型、infer和递归的正确上手姿势
这块是 Matt 内容里最容易劝退人的部分。很多同学一看到infer就头疼,但他在实际教学里其实踩点很克制——先用最简单的场景讲明白原理,再给你一个足以覆盖业务的深度。
4.1infer的本质是从已有类型里“抠”出一个类型
我先说一个最常用的场景:提取数组元素的类型。
type ElementType<T> = T extends (infer E)[] ? E : never type NumberList = ElementType<number[]> // number这里infer E的意思是:如果T能匹配到某个数组类型,那就把数组里的元素类型推断到E上。这个能力特别适合做类型工具。
另一个很经典的是ReturnType:
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never type Fn = () => string type Result = MyReturnType<Fn> // string光看这两段可能觉得没什么用,但组合起来就能处理实际场景。比如我想把 React 组件的onClick事件处理函数里的参数类型提取出来,写成工具函数给团队用。没有infer之前只能写any,有了infer之后可以做到完全类型安全。
4.2 递归类型:只写业务需要的深度
递归类型是另一座大山。最常见的需求是“深层可选”——把每个嵌套层级都变成可选属性。你当然可以直接用类型库里的DeepPartial,但我建议至少学会自己写一遍:
type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] }这段代码的思路不复杂:遍历所有属性,把每个属性设为可选;如果属性值本身是对象,就递归地再套一层DeepPartial。
我在实际项目里用它处理配置文件的合并,效果很好。但要注意一个点:别把递归写太深。类型层级的递归如果嵌套太多,会让 TypeScript 编译器变慢,甚至直接报递归深度限制。我试过给一个嵌套五六层的菜单结构写DeepPartial,结果编辑器直接卡了几秒。
更稳妥的做法是限制深度,只递归两层:
type DeepPartialTwoLevels<T> = { [K in keyof T]?: T[K] extends object ? { [P in keyof T[K]]?: T[K][P] } : T[K] }简单的东西永远比炫技好用,这是我实践中很重要的一个心得。
4.3 条件类型分配律:一个容易吃大亏的分支
条件类型有一个隐蔽行为:当传入的是联合类型时,条件会被拆开执行。也就是string | number extends X会变成对每个成员分别判断。这看起来是小事,但如果你不知道,很容易写出诡异的结果。
比如:
type IsString<T> = T extends string ? true : false type Result = IsString<string | number> // boolean为什么不是false?因为条件类型得到的是true | false,合并起来就是boolean。想防止这种分配行为,可以用中括号包一下T:
type IsString<T> = [T] extends [string] ? true : false type Result = IsString<string | number> // false这个技巧我在封装事件回调的时候用过。如果你要判断的泛型可能是联合类型,一定记住这点,否则会在奇怪的地方得到不可控的结果。
5. 技能四:工具链上的坚持——ts-reset 和类型兜底网
除了写类型,Matt 还维护了一个叫ts-reset的小工具库。它的知名度不如 Total TypeScript,但在实际项目中非常值得一试。
5.1ts-reset到底解决了什么问题
TypeScript 自带的一些内置类型其实非常“宽松”。最典型的是JSON.parse,它的返回类型是any。这意味着你调用JSON.parse后拿到的东西可以赋值给任何类型,不会报错。当上游数据格式变化时,any会在项目里悄悄蔓延。
ts-reset的思路是把这些宽松的类型改得更严格。比如让JSON.parse返回unknown,逼你手动做类型校验或断言:
// 安装 ts-reset 后,在项目入口引入 import '@total-typescript/ts-reset' const data = JSON.parse('{"name":"Matt"}') // data 类型是 unknown,不能再随意访问 data.name这时你会被迫去写一个isUser这种类型守卫函数,虽然麻烦,但长期看值得。
我用它改造了一个老项目,第一周确实增加了不少工作量,很多在any上跑通的代码开始报错。但跑了两个月之后,线上接口返回字段错乱的问题明显少了很多。养成“先验证再使用”的习惯,比任何 lint 规则都强。
5.2 什么时候该用,什么时候不必用
如果你正在写一个新的、准备长期维护的项目,我推荐直接引入。它的体积很小,也不会侵入你的业务代码。但如果你只是临时改一个脚本,或者整个项目已经被any占领了,那引不引意义不大——问题不在工具,而在代码习惯。
另外,ts-reset并不是银弹。它只覆盖了内置类型里那些明显不合理的部分,真正复杂的业务类型还是要靠你自己写。把它理解成一层“腰部防护”就好。
5.3 手动补一个安全兜底:类型守卫
有时候不想引入依赖,也可以用原生方式解决同一个问题。比如我常在项目里写一个小的类型守卫工具:
function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === 'object' && value !== null } const data: unknown = JSON.parse('{"name":"Matt"}') if (isRecord(data)) { console.log(data.name) }写了这个之后,所有从外部进来的数据都必须先过一道守卫,才能真正被当成对象使用。这个过程刚开始觉得繁琐,但养成习惯后,你会发现自己对接口数据形状的理解越来越深,反而能提前发现很多字段拼写问题。
6. 像他一样消化知识:复现、讲解、对抗遗忘
前面说的都是招式,这一部分算心法。Matt 之所以能一直保持高质量输出,不是因为他记性好,而是他有一套很朴素的学习方法。我把它拆成三步,已经在自己身上验证过。
6.1 第一步:不看答案,复现他的最小示例
看教程最常见的问题是“眼睛会了,手不会”。我现在的做法是:遇到一个觉得有用的视频,先把视频里的最终类型抄下来,然后把代码全部遮住,只留下题目,自己重新写一遍。
比如他讲infer的时候,最后留下一个需求:写一个类型,能从一个函数类型里取出参数的类型。我不会直接看答案,而是先尝试用最笨的方法写,实在写不出来再翻答案,然后问自己三个问题:
- 为什么我当时没想到用
infer? - 这个
infer的位置和结构是怎么从需求里推导出来的? - 如果需求再变一下(比如取出第二个参数),这个方式还能用吗?
这个过程比看十遍视频都管用。因为你在强迫自己补全那一段“空白思维”,而不是顺着作者的思路滑过去。
6.2 第二步:把一个类型工具讲给别人听
如果你能把一个知识点讲到别人懂,那你基本是真懂了。我会定期在公司内部做小分享,挑一个最近正在用的类型工具,从“业务痛点”讲到“实现原理”。
印象最深的是我讲DeepPartial那次。为了讲清楚递归边界,我专门画了几种不同层级的例子。讲完之后,有个同事提出一个问题:“那属性值是数组的时候怎么办?”我才发现自己没考虑到这个分支。后来补上数组递归处理,实现才变得完整。
这个过程中,最大的收获不是分享本身,而是你会发现自己理解里的空白点。那些你“以为懂”但其实没懂的地方,会在被提问时暴露出来。
6.3 第三步:用在自己项目的真实代码里,而不是练习文件
这是最后一步,也是最重要的一步。用练习文件做题是安全的,但不会改变你的直觉。真正让 Matt 那套技能变成你本能的方法,是在生产代码里替掉那些低质量类型。
我从一个很小的点开始:把项目里所有能改成可辨识联合的状态改成可辨识联合。做完整个模块之后,我又刻意去删掉一些as断言,逼自己用类型守卫替代。这个过程很痛苦,尤其是遇到接口数据不确定的时候。但一个月后,我发现自己写新代码时思路会先想“这个状态有几个分支,各自的字段是什么”,而不是“先写个对象再说”。
这可能就是 Matt 内容真正想传达的东西:类型不是事后补上去的装饰,而是你在设计代码时的一部分。
最后再分享一个我在实践中的体会:如果只能从他那儿带走一个最深的印象,那一定是“用最小的改动换取更强的类型安全”。别一上来就想着写一个万能类型工具,先把手头这个函数、这个状态定义清楚。随着项目和经验慢慢积累,那些复杂技巧自然会在你需要的时候浮出水面。这才是 Matt Pocock Skills 最值得学的地方。