news 2026/8/28 19:35:54

TypeScript 技能训练:从类型注解到高级类型实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
TypeScript 技能训练:从类型注解到高级类型实战

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写错、objnull、返回值类型不符合预期,全都得靠日志定位。更合理的版本用泛型约束参数和返回值:

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.js18 及以上运行 npm 命令,启动测试
TypeScript5.x类型检查与类型测试
Vitest1.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.jsonnode_modulestsconfig.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]>,否则保留原类型。

这里有三个常见的坑:

  1. 没有判断T[K]是否可能是数组。数组也是对象,但有特殊行为。数组类型递归时,如果不特殊处理,T[K]可能变成{ [key: number]: ... },丢失数组方法。
  2. 在非泛型对象上直接递归导致无限递归。比如T[K]DateMapSet等内建对象时,递归会把它们的内部结构全部展开,但实际项目通常希望保留它们的原始类型。
  3. 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:viewuser-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会告诉你T1string,但它不会说“这个实现是不是你想要的”。所以要加类型断言测试。

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

现象:函数传入对象后,返回值类型丢失,编辑器推导为unknownany

可能原因:没有在函数签名中建立TK之间的关系。常见写法:

function getValue<T>(obj: T, key: keyof T): any { return obj[key]; }

这里keykeyof 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

原因:递归没有终止条件,或者在处理anyunknown、内建对象时无限展开。

排查方式:缩小输入范围,先传两层对象看是否正常;再逐步增加层级;检查是否对函数、数组、Map、Set 做了基线处理。

解决方式:在递归入口处增加基线分支。对函数、原始类型直接返回自身,对数组只递归元素,对 Map/Set 使用ReadonlyMapReadonlySet

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 给新手的一个具体练习路径

如果只练三道题,推荐顺序是:

  1. 实现PickByType<T, U>:从一个对象中选出值类型匹配U的属性。这道题考察映射类型、条件类型和as重命名的配合。
  2. 实现Chainable:让对象链式调用具有类型推导能力,每调用一次 set 方法,返回类型都包含新属性。这道题考察泛型参数的累积。
  3. 实现CamelCase<T>:把下划线字符串转换为驼峰字符串。这道题考察模板字面量类型和递归字符串处理。

这三题分别覆盖对象层、函数层、字符串层,练完后 TypeScript 高级类型的三个主要方向就都有了手感。

7.4 回到 mattpocock / skills 的实践启示

mattpocock / skills 这类训练资源提醒开发者一件事:类型技能是“可训练”的,不需要把它看成少数人的天赋。训练方式不是背语法,而是不断做题、看错误、补边界、写测试。项目里的每一个any、每一个as断言,都可以变成一次小型训练机会。当你开始质疑“这个类型为什么推导成了 never”而不是直接as any时,技能增长就开始了。

下一步你可以做两件事:把文章里的四道题完整跑通,并提交到自己的 GitHub 练习仓库;然后在实际项目里找一个any出现频率最高的模块,用泛型、条件类型或映射类型重写一层,观察类型错误在开发阶段提前暴露的效果。这样练出来的类型能力,不是停留在文档上的知识,而是下一次写公共组件、重构接口函数时真正会使用的工具。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/28 19:32:45

C++泛型编程实战:从函数模板到动态内存管理,构建通用比较器

1. 项目概述&#xff1a;一个“比大小”函数背后的编程哲学最近在带新人&#xff0c;发现一个挺有意思的现象&#xff1a;很多刚接触C的朋友&#xff0c;一听到“写个函数比较两个数大小”&#xff0c;觉得这太简单了&#xff0c;不就是个if-else吗&#xff1f;但当我把需求改成…

作者头像 李华
网站建设 2026/8/28 19:32:15

雅思写作全攻略:系统课程+备考资料包(含视频/模板/库)

温馨提示&#xff1a;文末有联系方式 **&#x1f525; 雅思写作系统化学习方案上线** 专为冲刺雅思写作高分打造的完整学习包&#xff0c;涵盖从基础入门到冲刺提分的全流程内容&#xff0c;助你逻辑清晰、语言地道、轻松突破6.5。 **&#x1f4bb; 全平台适配即刻启用** 纯…

作者头像 李华
网站建设 2026/8/28 19:24:51

时间序列建模实战:从ARIMA到SARIMA,手把手预测山猫数量

1. 项目概述&#xff1a;从山猫数量预测看时间序列建模的实战价值刚接触数学建模的朋友&#xff0c;常常会困惑于如何将课本上的理论转化为一个能解决实际问题的完整项目。我当年也是从一堆抽象的公式和算法里摸爬滚打过来的&#xff0c;深知“纸上得来终觉浅”的道理。今天&am…

作者头像 李华
网站建设 2026/8/28 19:21:36

i.MX8M Nano树莓派外形SBC解析:工业级嵌入式开发与选型指南

前阵子在一个嵌入式交流群里看到有人聊“i.MX8M Nano出现在一块树莓派样子的板子上”&#xff0c;第一反应是&#xff1a;树莓派又要被“平替”了&#xff1f;但把规格翻完之后&#xff0c;我的判断变了——这根本不是在跟树莓派抢桌面市场&#xff0c;而是NXP这套成熟的工业级…

作者头像 李华
网站建设 2026/8/28 19:18:59

PROFINET通信实战:西门子PLC与基恩士IV视觉系统集成指南

简介&#xff1a;PROFINET作为工业以太网的核心协议&#xff0c;实现了控制器与现场设备间的高速、确定性数据交换。其原理基于IO控制器与IO设备的实时通信通道建立&#xff0c;通过GSDML文件描述设备特性&#xff0c;并利用智能设备&#xff08;I-Device&#xff09;模式实现复…

作者头像 李华