TypeScript 技能训练并不等于“多看几篇文档”。很多开发者学了接口、泛型、联合类型之后,仍然在真实项目里把类型写成了“装饰”,遇到条件类型、infer、映射类型、模板字面量类型就绕道走。mattpocock / skills 这类仓库之所以被关注,是因为它把 TypeScript 训练变成了一条可执行的路径:用类型题、类型挑战和贴近真实业务的类型重构,推动开发者从“会写类型注解”走向“能设计类型系统”。这篇文章不介绍仓库怎么收藏,而是沿着同一套训练思路,带你搭建一个可复现的 TypeScript 技能练习环境,并用一组典型类型题验证学习效果。
文章适合两类读者:第一类是已经能写业务代码、但类型能力停留在基础的开发者;第二类是团队里负责前端基建、想把 TypeScript 规范落下去的人。文章会从理念说到环境,从典型类型题说到实际项目落地,最后给出常见坑和排查链路。整个训练方式不依赖某个具体框架,React、Vue、Node 项目都能迁移。
1. mattpocock / skills 的训练思路:类型能力不是记出来的,是“做题 + 重构”出来的
先理解这套思路的核心。绝大多数 TypeScript 学习资料都按“基础类型、接口、泛型、高级类型”组织章节,读的时候每个知识点都懂,写代码时却不知道用在哪里。mattpocock 的教学体系更强调一个判断:类型能力高低,体现在开发者能否通过类型推断做“提前验证”,而不是等到运行时才发现错误。
1.1 为什么“做题”比“看文档”更适合进阶
看文档是线性接收信息,做类型题则要求你同时处理三个维度:
- 输入类型是什么:你要先判断函数、对象或接口的入参结构。
- 推断过程是什么:TypeScript 编译器如何从入参推导出出参类型。
- 边界情况是什么:空数组、undefined、只读属性、联合类型分支是否都被覆盖。
举例来说,一个简单的getValue函数,初级写法可能直接用any:
function getValue(obj: any, key: string): any { return obj[key]; }这种写法在编译阶段没有任何问题,但进入运行时后,key写错、obj为null、返回值类型不符合预期,全都得靠日志定位。更合理的版本用泛型约束参数和返回值:
function getValue<T extends object, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; }这就是一个“最小类型训练”的样本:输入、推导、边界都被覆盖了。T被约束为对象,K被约束为T的键,返回值类型由T[K]推导。调用getValue({ name: 'ts' }, 'name'),编辑器会正确提示返回string;调用不存在的键,编辑器直接报错。这个差异是“类型技能”和“类型语法”的分水岭。
mattpocock / skills 这条链路的关键是把这种样本变成系统练习:先给一个类型工具函数或一个业务场景的残缺版本,要求你补全类型;补全后跑类型测试,再用不同入参验证边界。它强调的不是“你记住了一个工具类型”,而是“你能不看提示,独立设计出工具类型”。
1.2 “技能训练”与日常工作的关系
在真实项目中,你可能不会每天写复杂工具类型,但这套能力会渗透在很多隐性任务里:
- 封装 API 请求时,能否用泛型让
request<T>(url: string): Promise<T>在不同接口下自动推断返回类型。 - 处理表单状态时,能否用映射类型让
partialState保持与FormState的键一致。 - 做权限判断时,能否用模板字面量类型约束
'view' | 'edit' | 'delete'与资源名的组合。 - 重构公共组件时,能否用条件类型让某个属性在
variant='primary'时出现,在variant='text'时消失。
这些场景都不需要你背工具类型名,但需要你理解类型编程的“语法规则”和“推断顺序”。这就是技能训练在生产项目里的映射。
注意:不是所有代码都值得写成复杂工具类型。技能训练是让你“会写”,业务落地时要先问一个问题:这个类型复杂度如果超过运行时逻辑复杂度,是否需要简化。
2. 先搭建一个可复现的 TypeScript 类型练习环境
不要把类型练习直接放进大型业务项目里。大型项目依赖复杂、tsconfig 约束多,一个类型报错可能会混入大量无关错误。更推荐的方式是单独建一个练习仓库,只保留 TypeScript 编译器和一个轻量测试框架,让每一次练习都有即时反馈。
2.1 环境要求与版本准备
准备这套环境需要 Node.js 和 npm(或 pnpm、yarn)。实际训练前,先确认版本:
| 工具 | 建议版本 | 用途 |
|---|---|---|
| Node.js | 18 及以上 | 运行 npm 命令,启动测试 |
| TypeScript | 5.x | 类型检查与类型测试 |
| Vitest | 1.x 或 2.x | 提供类型断言能力 |
原始材料通常不会给出固定版本,落地时以你本地的实际版本为准。如果 TypeScript 版本低于 4.5,模板字面量类型、递归条件类型等能力会受限,建议先升级到 5.x。
创建目录并初始化项目:
mkdir ts-skills cd ts-skills npm init -y npm install -D typescript vitest npx tsc --init这会生成package.json、node_modules和tsconfig.json。需要确认tsconfig.json中的几个关键选项:
{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "noEmit": true, "skipLibCheck": true } }strict必须开启。TypeScript 的类型练习如果不开strict,很多边界情况会被 null、undefined 隐式放过,练出来的判断力会失真。noEmit保证我们只做类型检查,不生成 JS 文件。
2.2 添加类型测试能力
纯类型训练可以只靠tsc --noEmit验证,但缺点是肉眼检查。更可靠的验证方式是使用 Vitest 的类型断言能力,把它们写成一个可执行测试。
在package.json添加脚本:
{ "scripts": { "typecheck": "tsc --noEmit", "test": "vitest run" } }创建测试文件时,会用到vitest提供的expectTypeOf。它的作用不是做运行时断言,而是专门验证 TypeScript 的类型推导结果:
import { describe, it, expectTypeOf } from 'vitest'; type GetName<T> = T extends { name: infer N } ? N : never; describe('类型练习', () => { it('应能从对象中提取 name 类型', () => { expectTypeOf<GetName<{ name: string; age: number }>>().toEqualTypeOf<string>(); }); it('应在没有 name 属性时返回 never', () => { expectTypeOf<GetName<{ age: number }>>().toEqualTypeOf<never>(); }); });运行npm test后,Vitest 会执行两个层面的校验:类型层面看GetName推导结果是否符合断言,运行层面看测试结构是否正确。类型错误会导致测试失败,错误信息包含编译器给出的具体类型差异。
2.3 建议的目录结构
练习仓库建议按“主题 + 题目编号”组织,方便回顾:
ts-skills/ ├── package.json ├── tsconfig.json ├── vitest.config.ts └── src/ ├── 01-generics/ │ ├── get-value.ts │ ├── get-value.test.ts │ └── solution.ts ├── 02-conditional-types/ │ ├── return-type.ts │ └── return-type.test.ts └── 03-template-literal-types/ ├── permission.ts └── permission.test.ts每个主题下可以放三类文件:题目文件(留空类型定义)、测试文件(写断言)、题解文件(补全实现)。练习时只看题目文件和测试文件,思考后打开题解文件对照。
注意:独立练习仓库的好处是反馈链路短、错误信息干净。生产项目里的 tsconfig 往往受构建工具、插件和编译目标影响,不适合做高密度类型训练。
3. 一组核心类型题:从“看懂”到“能独立写出来”
环境准备好之后,就可以进入核心训练环节。这一节设计四道有递增关系的类型题,覆盖 TypeScript 高级类型中最常用的四个能力点:泛型约束、条件类型 + infer、映射类型 + as、模板字面量类型。每个题目都按“需求 -> 实现 -> 边界 -> 验证”的顺序展开,这正是技能训练的主体。
3.1 泛型约束:让函数既能保持类型关系,又不至于无限放宽
题目:实现一个pick函数,输入对象和一组键,返回一个新对象。要求返回值只包含这组键对应的属性,并且每个属性的类型不能丢失。
先写一个不合理的版本:
function pick(obj: Record<string, unknown>, keys: string[]): Record<string, unknown> { const result: Record<string, unknown> = {}; for (const key of keys) { if (key in obj) { result[key] = obj[key]; } } return result; }这个版本能执行,但类型上等于没说。调用方拿到的结果永远是Record<string, unknown>,必须手动断言才能访问具体属性。
更合理的设计:
function pick<T extends object, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> { const result = {} as Pick<T, K>; for (const key of keys) { if (key in obj) { result[key] = obj[key]; } } return result; }关键点有两个:
K extends keyof T约束了keys数组里的每一项必须是obj的键,传入不存在的键会直接编译报错。- 返回值
Pick<T, K>由内置工具类型生成,它保证返回对象的键集合与传入keys的联合类型一致,且值类型保留。
验证样例:
const user = { name: 'tom', age: 3, address: 'unknown' }; const picked = pick(user, ['name', 'age']); // picked 的类型是 { name: string; age: number } picked.address; // 编译报错:属性不存在这道题检验的是泛型基本功。不涉及条件类型,但你必须理解keyof的作用、内置工具类型Pick背后的映射逻辑,以及as断言在什么情况下才可接受。
3.2 条件类型 + infer:提取函数返回值类型
题目:实现一个自定义工具类型MyReturnType<T>,它返回函数类型T的返回值类型。如果T不是函数类型,返回never。
这是 TypeScript 提供的ReturnType工具类型的重新实现,核心语法是条件类型与infer:
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;逐段理解:
T extends (...args: any[]) => infer R是条件判断,判断T是否能匹配函数类型。infer R不是直接声明一个类型变量,而是让 TypeScript 在匹配过程中推断出返回值类型并赋值给R。- 条件成立时取
R,否则取never。
边界情况需要单独考虑:
type T1 = MyReturnType<() => string>; // string type T2 = MyReturnType<(a: number, b: number) => number[]>; // number[] type T3 = MyReturnType<string>; // never type T4 = MyReturnType<Promise<string>>; // never,因为 Promise 不是函数多写几组边界测试,才能真正理解 infer 的作用位置。如果函数类型里出现this参数、重载、泛型函数,情况会更复杂,但初级训练先聚焦在最常见的函数签名上。
对应的类型测试:
import { describe, it, expectTypeOf } from 'vitest'; import type { MyReturnType } from './my-return-type'; describe('MyReturnType', () => { it('普通函数返回字符串', () => { expectTypeOf<MyReturnType<() => string>>().toEqualTypeOf<string>(); }); it('带参数函数返回数组', () => { expectTypeOf<MyReturnType<(a: number, b: boolean) => number[]>>().toEqualTypeOf<number[]>(); }); it('非函数返回 never', () => { expectTypeOf<MyReturnType<number>>().toEqualTypeOf<never>(); }); });这里最容易犯的错是把infer R写在参数位置而不是返回值位置,或者忘记处理非函数分支。排查时看错误信息里的infer位置,以及条件类型是否走到了 else 分支。
3.3 映射类型 + as:让对象的所有属性变为只读且值类型转换
题目:实现DeepReadonly<T>,让一个对象的所有层级属性都变为只读,包括嵌套对象。这要求递归处理每个属性。
先看浅层版本:
type ReadonlyObject<T> = { readonly [K in keyof T]: T[K]; };这个是内置Readonly<T>的实现思路,但readonly只作用于第一层。对于嵌套对象:
type DeepReadonly<T> = { readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]; };解释这行代码:
[K in keyof T]遍历T的每一个键。readonly给当前层属性添加只读约束。T[K] extends object判断当前属性值是否为对象,如果是,则递归调用DeepReadonly<T[K]>,否则保留原类型。
这里有三个常见的坑:
- 没有判断
T[K]是否可能是数组。数组也是对象,但有特殊行为。数组类型递归时,如果不特殊处理,T[K]可能变成{ [key: number]: ... },丢失数组方法。 - 在非泛型对象上直接递归导致无限递归。比如
T[K]是Date、Map、Set等内建对象时,递归会把它们的内部结构全部展开,但实际项目通常希望保留它们的原始类型。 Function类型也是对象,但通常不需要递归到函数内部。
更稳妥的版本:
type DeepReadonly<T> = T extends (...args: never[]) => unknown ? T : T extends Map<infer K, infer V> ? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>> : T extends Set<infer U> ? ReadonlySet<DeepReadonly<U>> : T extends Array<infer E> ? ReadonlyArray<DeepReadonly<E>> : T extends object ? { readonly [P in keyof T]: DeepReadonly<T[P]> } : T;这个版本说明一件事:类型编程不是“越短越好”,而是“边界处理越多越可靠”。实际业务中很少需要完整处理 Map、Set,但如果你要封装给团队使用,就必须考虑这些内建类型。
简单验证:
interface Config { name: string; nested: { path: string; retry: number; }; } type ReadonlyConfig = DeepReadonly<Config>; const config: ReadonlyConfig = { name: 'a', nested: { path: '/api', retry: 3 }, }; // config.nested.path = '/x'; // 编译报错:无法分配到只读属性这道题的关键收获是:映射类型能保持对象的键结构,条件类型负责区分“需要递归”和“不需要递归”的属性。两者结合才是一个工具类型能进入生产代码的原因。
3.4 模板字面量类型:把字符串组合变成可检查的类型约束
题目:实现一个权限字符串类型,要求它只能由'view'、'edit'、'delete'三种操作与资源名的组合而成,格式固定为资源名:操作。
最直接的写法是用联合类型做精确枚举:
type Resource = 'user' | 'post' | 'comment'; type Permission = `${Resource}:${'view' | 'edit' | 'delete'}`;TypeScript 的模板字面量类型会把Resource联合类型与后面的操作联合类型做笛卡尔积,最终生成:
type Permission = | 'user:view' | 'user:edit' | 'user:delete' | 'post:view' | 'post:edit' | 'post:delete' | 'comment:view' | 'comment:edit' | 'comment:delete';有了这个类型,权限判断函数就能避免字符串拼接错误:
function checkPermission(permission: Permission): boolean { // 业务逻辑 return true; } checkPermission('user:view'); // 合法 checkPermission('view:user'); // 编译报错:不符合模板字面量类型进阶题目可以继续增加泛型参数:
type Role = 'admin' | 'user'; type RolePermission<R extends Role> = `${R}-${Permission}`;这样admin-user:view与user-admin:view是不同结构,类型系统能把业务规则前置到编译阶段。
这道题的价值在于:它让开发者意识到 TypeScript 类型系统不只是描述“值的形状”,还能描述“字符串的合法格式”。在接口参数校验、事件名约束、路由路径类型安全等场景中非常有用。
4. 用类型测试验证每一步:反馈回路是技能提升的关键
做完类型题之后,必须回到测试环境验证。很多人在编辑器里看到一个类型不报错,就认为“类型正确”,这是训练里最大的误区。类型不报错只能说明“在你当前提供的最小输入下,类型推导没有失败”,不能说明“所有输入都符合预期”。
4.1 验证方式一:编译检查
编译检查是最基本的反馈:
npx tsc --noEmit如果项目里没有任何错误,控制台不会输出任何内容。这个环节只能发现“类型不匹配”,不能发现“类型推导是否符合预期”。例如:
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never; type T1 = MyReturnType<() => string>;tsc会告诉你T1是string,但它不会说“这个实现是不是你想要的”。所以要加类型断言测试。
4.2 验证方式二:类型测试
类型测试把“符合预期”变成机器可检查的断言。前面创建my-return-type.test.ts之后,运行:
npm test预期结果:
Test Files 3 passed (3) Tests 12 passed (12)如果某个测试失败,Vitest 会显示实际推导与声明的差异:
- Expected: never + Received: undefined这种反馈非常直观。它告诉你的不是“代码运行崩溃”,而是“类型系统理解出来的结果和你设计的约束不一致”。
使用expectTypeOf时有一个细节要区分:
toEqualTypeOf<X>():要求两个类型完全相同。toEqualTypeOf<X | undefined>():要求包含 undefined 分支。extract/exclude:用于验证工具类型在边界情况下的分支。
我给一个更完整的测试示例:
import { describe, it, expectTypeOf } from 'vitest'; import type { DeepReadonly } from './deep-readonly'; describe('DeepReadonly', () => { it('普通对象每一层都应只读', () => { type Source = { a: { b: { c: number } } }; type Result = DeepReadonly<Source>; expectTypeOf<Result>().toEqualTypeOf<{ readonly a: { readonly b: { readonly c: number; }; }; }>(); }); it('数组应保持数组结构且元素只读', () => { type Source = { list: Array<{ id: number }> }; type Result = DeepReadonly<Source>; expectTypeOf<Result>().toMatchTypeOf<{ list: ReadonlyArray<{ readonly id: number }>; }>(); }); it('函数类型不应被递归', () => { type Source = { handler: () => void }; type Result = DeepReadonly<Source>; expectTypeOf<Result['handler']>().toEqualTypeOf<() => void>(); }); });这样,每当你修改工具类型实现,运行一次测试就能立刻知道 12 个边界中有没有退化。这种反馈回路就是技能训练的核心:不是“我改完代码,看着没问题”,而是“所有已知边界都通过了机器验证”。
4.3 验证方式三:编译器错误信息反推
训练过程中会频繁看到不同类型的报错,常用的几类:
| 错误信息特征 | 常见原因 | 处理方向 |
|---|---|---|
Type 'xxx' is not assignable to type 'yyy' | 赋值方向不匹配,或联合类型分支未被覆盖 | 检查泛型约束、条件类型分支 |
Property 'xxx' does not exist on type 'yyy' | 对象类型缺少属性,或 keyof 约束不正确 | 检查映射类型、索引访问类型 |
Type instantiation is excessively deep and possibly infinite | 递归条件类型没有终止条件 | 增加边界判断,如数组、函数、内建类型 |
Argument of type 'string' is not assignable to parameter of type ... | 字面量类型被拓宽成了 string | 使用as const或显式标注 |
看到这些错误不要急着用as any绕过。先回到类型定义里找“条件分支”或“约束边界”是否写错。
5. 从训练题到生产:把这些类型能力放进真实项目
类型题训练的价值最终要在业务代码里兑现。下面三个场景几乎在每个中大型前端项目中都会出现:API 请求类型、状态管理、组件属性联动。这里不讨论具体框架,只描述通用的类型设计思路。
5.1 API 返回值类型:用泛型减少重复定义
很多项目会在每个请求函数里单独写返回类型:
async function fetchUser(): Promise<User> { const res = await fetch('/api/user'); return res.json(); } async function fetchPost(): Promise<Post> { const res = await fetch('/api/post'); return res.json(); }这种写法没有错,但每个请求函数都要处理 loading、error 和响应结构,类型会重复。更推荐的设计是基础请求函数带泛型:
interface ApiResponse<T> { code: number; message: string; data: T; } async function request<T>(url: string, options?: RequestInit): Promise<T> { const res = await fetch(url, options); if (!res.ok) { throw new Error(`HTTP ${res.status}`); } const body = (await res.json()) as ApiResponse<T>; if (body.code !== 0) { throw new Error(body.message); } return body.data; } const user = await request<User>('/api/user'); // user 的类型是 User这里的关键不是request<T>本身,而是它让每个调用方都能在“不重复声明 Promise ”的前提下获得精确返回类型。如果后端接口失败时返回错误结构,还需要用联合类型表示:
type Result<T> = { ok: true; value: T } | { ok: false; error: string }; async function request2<T>(url: string): Promise<Result<T>> { try { const res = await fetch(url); const data: T = await res.json(); return { ok: true, value: data }; } catch (e) { return { ok: false, error: e instanceof Error ? e.message : 'unknown' }; } }使用时可区分:
const result = await request2<User>('/api/user'); if (result.ok) { // result.value 是 User,编辑器自动收敛 } else { // result.error 是 string }这种可辨识联合类型在训练中练过一次,生产里就能自然使用。
5.2 表单状态:用映射类型保持键的一致性
表单状态很容易出现“初始化字段和提交字段不一致”的问题。用映射类型可以约束:
interface FormState { username: string; password: string; remember: boolean; } type FormTouched<T> = { [K in keyof T]?: boolean }; type FormErrors<T> = { [K in keyof T]?: string }; const touched: FormTouched<FormState> = { username: true, password: false }; const errors: FormErrors<FormState> = { username: '用户名不能为空' };FormTouched<FormState>保证了touched只能包含FormState的键,添加email会立即报错,删除password后字段不报错但类型上就不允许写入。现在很多表单库内置了类似能力,但理解它的来源能帮助你写出更贴合业务的自定义表单状态。
5.3 组件属性联动:条件类型让“某个属性互斥”成立
在组件开发中,经常有这样的需求:当variant='link'时,必须传href,且不能传onClick;当variant='button'时,必须传onClick,不能传href。这可以用条件类型建模:
type ButtonVariant = 'button' | 'link'; type ButtonProps<V extends ButtonVariant = 'button'> = { variant: V; label: string; } & (V extends 'link' ? { href: string; onClick?: never } : { href?: never; onClick: () => void });用法:
const linkButton: ButtonProps<'link'> = { variant: 'link', label: '文档', href: '/docs', }; const normalButton: ButtonProps<'button'> = { variant: 'button', label: '提交', onClick: () => {}, };如果给'link'变体传了onClick,TypeScript 会报错,因为它被定义为never。这个设计模式在 Vue 和 React 的组件 props 体系中都能使用。它也是进阶的类型训练题:辨别&交叉类型、?可选属性和条件类型如何共同形成“互斥约束”。
6. 常见类型错误与排查链路
类型题做多了,错误模式会集中浮现。下面整理出训练和业务中都高频出现的四类问题,每一类都按“现象 -> 原因 -> 排查 -> 解决”的顺序写。
6.1 泛型约束写错位置,导致类型推导全部变成 unknown 或 any
现象:函数传入对象后,返回值类型丢失,编辑器推导为unknown或any。
可能原因:没有在函数签名中建立T与K之间的关系。常见写法:
function getValue<T>(obj: T, key: keyof T): any { return obj[key]; }这里key是keyof T而不是泛型K extends keyof T,返回值也没有用T[K]建立映射,所以类型系统只知道“key 是某个键”,不知道“具体是哪一个键”。
排查方式:把鼠标悬停在函数返回类型上,看推断结果;把返回值改为T[keyof T],错误信息会暴露更多细节。
推荐解决:
function getValue<T extends object, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; }6.2 条件类型的三元嵌套太难读,分支判断错误
现象:写一个工具类型时,多次extends嵌套后,某个分支永远不成立。
原因:条件类型判断extends时,会在每个分支上展开联合类型。如果左侧是联合类型string | number,判断时是分别匹配:
type Check<T> = T extends string ? 'is-string' : 'not-string'; type A = Check<string | number>; // 'is-string' | 'not-string'如果你期望的是“整体判断后的单一结果”,需要引入数组或元组来关闭分配律:
type CheckAll<T> = [T] extends [string] ? 'is-string' : 'not-string'; type B = CheckAll<string | number>; // 'not-string'排查方式:对工具类型传入一组明确的联合类型,看推导结果;再用[T]包一层,观察是否变化。
推荐解决:在复杂条件类型里,先用嵌套三元写出逻辑,再把稳定分支提取为独立类型别名,最后用测试用例锁定行为。
6.3 字面量类型被拓宽成 string
现象:const permission = 'user:view'的类型是string,传给Permission类型参数时报错。
原因:const声明对象属性时,没有用as const。对象属性默认会被拓宽为 string,而模板字面量类型要求精确的联合类型。
排查方式:悬停查看变量的推导类型,判断是string还是'user:view'。
解决方式:
const permission = { action: 'user:view', } as const; // permission.action 的类型是 'user:view'如果是在函数参数中,可以声明:
function setPermission(action: 'user:view' | 'user:edit') {} const action = 'user:view' as const; setPermission(action);6.4 递归类型过深,编译器报“excessively deep”
现象:自定义递归工具类型传入完整业务对象后,TypeScript 报Type instantiation is excessively deep and possibly infinite。
原因:递归没有终止条件,或者在处理any、unknown、内建对象时无限展开。
排查方式:缩小输入范围,先传两层对象看是否正常;再逐步增加层级;检查是否对函数、数组、Map、Set 做了基线处理。
解决方式:在递归入口处增加基线分支。对函数、原始类型直接返回自身,对数组只递归元素,对 Map/Set 使用ReadonlyMap、ReadonlySet。
type DeepReadonly<T> = T extends (...args: any[]) => any ? T : T extends Array<infer U> ? ReadonlyArray<DeepReadonly<U>> : T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T;生产环境如果出现这个错误,优先考虑业务对象里是否有循环引用。如果一个对象在运行时本来就存在循环引用,类型系统层面也应该先避免无休止递归,通常在工具类型里用“深层限制到 3 或 5 层”的约束来处理。
7. 一套可复用的 TypeScript 技能训练清单与扩展方向
最后把这套训练方法沉淀成清单,方便你按周或按月执行,也方便团队内部做 Code Review 前自查。
7.1 日常练习清单
- 环境检查:确认 TypeScript 版本是 5.x,strict 开启,
noEmit开启。 - 输入检查:每个类型题至少准备 3 组输入:正常输入、边界输入、非法输入。
- 工具类型检查:泛型是否建造成了类型之间的逻辑关系,而不是返回
any;条件类型是否覆盖了 else 分支;映射类型是否考虑嵌套对象;模板字面量类型是否检查过联合类型的笛卡尔积范围。 - 测试检查:用
expectTypeOf覆盖正常分支、错误分支、继承分支。 - 生产迁移检查:这个工具类型是否解决真实问题;复杂度是否明显大于收益;有没有团队成员能读懂。
7.2 学习路线建议
| 阶段 | 训练主题 | 目标 | 练习量 |
|---|---|---|---|
| 第一阶段 | 基础泛型、keyof、索引访问类型 | 能写类型安全的通用函数 | 每天 2 题,持续 2 周 |
| 第二阶段 | 条件类型、infer、内置工具类型实现 | 能独立实现 ReturnType、Parameters、Pick、Readonly | 每天 1 题,重点做边界测试 |
| 第三阶段 | 映射类型、as 重映射、递归类型 | 能实现 DeepReadonly、DeepPartial、DeepRequired | 每周 2 题,放入真实对象验证 |
| 第四阶段 | 模板字面量类型、可辨识联合、交叉类型互斥 | 能设计组件 props 约束、事件名约束、权限字符串 | 结合业务场景每周 1 题 |
每个阶段都可以直接在独立练习仓库里做,做完用npm test验证。
7.3 给新手的一个具体练习路径
如果只练三道题,推荐顺序是:
- 实现
PickByType<T, U>:从一个对象中选出值类型匹配U的属性。这道题考察映射类型、条件类型和as重命名的配合。 - 实现
Chainable:让对象链式调用具有类型推导能力,每调用一次 set 方法,返回类型都包含新属性。这道题考察泛型参数的累积。 - 实现
CamelCase<T>:把下划线字符串转换为驼峰字符串。这道题考察模板字面量类型和递归字符串处理。
这三题分别覆盖对象层、函数层、字符串层,练完后 TypeScript 高级类型的三个主要方向就都有了手感。
7.4 回到 mattpocock / skills 的实践启示
mattpocock / skills 这类训练资源提醒开发者一件事:类型技能是“可训练”的,不需要把它看成少数人的天赋。训练方式不是背语法,而是不断做题、看错误、补边界、写测试。项目里的每一个any、每一个as断言,都可以变成一次小型训练机会。当你开始质疑“这个类型为什么推导成了 never”而不是直接as any时,技能增长就开始了。
下一步你可以做两件事:把文章里的四道题完整跑通,并提交到自己的 GitHub 练习仓库;然后在实际项目里找一个any出现频率最高的模块,用泛型、条件类型或映射类型重写一层,观察类型错误在开发阶段提前暴露的效果。这样练出来的类型能力,不是停留在文档上的知识,而是下一次写公共组件、重构接口函数时真正会使用的工具。