简介:这份资源是一套基于 TypeScript 与 React 的前端项目源码包,面向希望学习现代前端工程化配置、脚手架搭建与构建流程的开发者,尤其适合已掌握 JavaScript 基础、想进一步熟悉 npm 脚本与打包机制的中级学习者。压缩包共收录 1080 个文件,整体约 4.79MB,其中以 622 个 js 文件为主体,辅以 151 个 md 说明文档、108 个 json 配置、28 个 ts 与 14 个 tsx 源码文件,另有 scss 样式、yml 流水线配置、eslintrc 校验规则及 svg、png 等静态资源,覆盖开发、测试、构建到部署的完整链路。项目内置 npm start、npm test、npm run build 与 npm run eject 等脚本,可分别用于开发模式热更新调试、交互式测试、生产环境优化打包以及构建配置的完全暴露,便于读者深入理解 webpack、Babel、ESLint 等工具的协作方式。目前已有 335 人学习下载,适合作为前端工程化入门与配置调优的参考案例。
1. 从一份 TypeScript 类型体操题集说起:它到底能解决什么
如果你写过半年以上的 TypeScript,大概率遇到过这种场景:接口返回的 JSON 结构嵌套了三四层,手写 interface 写到怀疑人生;或者团队里有人把any当万能药,代码跑起来没问题,重构时却像拆盲盒。这份资源就是冲着这类问题来的——它是一套围绕 TypeScript 类型系统设计的实战题集,包含从基础类型推导到条件类型、映射类型、模板字面量类型的完整练习链路,每个题目都配了可运行的验证代码。
它适合两类人:一是已经会写业务代码、但类型标注全靠 IDE 提示的开发者,想系统补上类型推导这块短板;二是准备面试或想深入理解类型编程的从业者,需要一套有梯度的题目来练手。不适合完全没写过 TypeScript 的新手,因为题目默认你熟悉泛型、联合类型、交叉类型这些基础概念。整套资源的核心价值在于:把「类型即代码」这个抽象理念,拆成了可以逐题验证的具体操作,每道题都有明确的输入输出约束,做没做对跑一遍就知道,不用靠玄学判断。
2. 类型体操的底层逻辑:条件类型与 infer 到底怎么推
2.1 条件类型不是 if-else,它是类型层面的模式匹配
很多人第一次看到T extends U ? X : Y会下意识当成 JavaScript 的三元表达式,但它在类型系统里的行为更接近模式匹配。关键区别在于:条件类型是分布式的,当T是联合类型时,它会自动分发到每个成员上分别计算。这个特性既是类型体操的基石,也是翻车重灾区。
举个例子,定义一个Exclude<T, U>的简化版:
// 当 T 是联合类型时,条件类型会自动分发 type MyExclude<T, U> = T extends U ? never : T; // 验证:'a' | 'b' | 'c' 中排除 'a' type Result = MyExclude<'a' | 'b' | 'c', 'a'>; // 实际结果:'b' | 'c'逻辑说明:T是'a' | 'b' | 'c',条件类型把每个成员单独拿出来和U比较。'a' extends 'a'成立返回never,另外两个不成立返回自身,最后联合起来就是'b' | 'c'。参数U在这里是排除目标,可以是单个字面量也可以是联合类型。
这里有个容易忽略的边界:如果T是never,条件类型会直接返回never,不会走分发逻辑。我一般会在工具类型开头加一句[T] extends [never] ? ...来显式处理空联合的情况,避免推导结果不符合预期。
2.2 infer 的本质是占位符,但位置决定行为
infer只能在条件类型的extends子句里使用,它的作用是声明一个类型变量,让 TypeScript 在匹配过程中自动推导出具体类型。位置不同,推导结果差异很大。
// 提取函数返回值类型 type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never; // 提取数组元素类型 type ElementType<T> = T extends (infer E)[] ? E : never; // 验证 type Fn = () => { name: string; age: number }; type R = MyReturnType<Fn>; // { name: string; age: number } type Arr = Array<number | string>; type E = ElementType<Arr>; // number | string逻辑说明:第一个例子中infer R出现在函数返回位置,TypeScript 会把Fn的返回类型推导给R。第二个例子中infer E出现在数组元素位置,推导出联合类型。参数T必须是函数类型或数组类型,否则走never分支。
常见做法是把多个infer组合使用,比如提取 Promise 的 resolve 类型:T extends Promise<infer V> ? V : T。但要注意,如果T是Promise<Promise<number>>,只会剥一层,需要递归处理。递归深度超过 TypeScript 默认限制会报错,一般控制在 10 层以内比较安全。
2.3 映射类型与键重映射:批量改造对象结构
映射类型用来遍历对象的键并生成新类型,配合as子句可以做键重映射。这在处理 API 响应数据转换时特别实用。
// 把对象所有属性变成可选,并添加前缀 type PrefixedOptional<T> = { [K in keyof T as `prefix_${string & K}`]?: T[K]; }; // 验证 interface User { name: string; age: number; } type PrefixedUser = PrefixedOptional<User>; // 结果:{ prefix_name?: string; prefix_age?: number }逻辑说明:[K in keyof T]遍历T的所有键,as子句把键重命名为模板字面量类型。string & K是为了让K满足模板字面量的约束,因为keyof T可能是string | number | symbol。?把属性变成可选,T[K]保留原类型。
参数调整空间很大:可以把?去掉变成必选,可以把prefix_换成其他前缀,也可以把T[K]包一层Readonly或Partial。我一般会先写一个基础版本跑通,再根据实际数据结构逐步加约束,避免一次性写太复杂导致推导失败时无从排查。
3. 从零跑通一套类型题:环境配置与逐题验证流程
3.1 最小化环境搭建:tsc 加 tsd 就够了
这套资源不依赖任何框架,核心验证工具是 TypeScript 编译器加类型断言库。我一般用tsd来做类型层面的单元测试,因为它能直接断言类型相等,比// @ts-expect-error更直观。
# 初始化项目 mkdir ts-type-challenges && cd ts-type-challenges npm init -y # 安装依赖 npm install typescript tsd --save-dev # 生成 tsconfig.json npx tsc --init逻辑说明:tsd是一个专门做类型断言的工具,它读取.test-d.ts文件里的expectType和expectError来验证类型。tsconfig.json需要开启strict: true,否则很多类型错误不会被捕获。参数方面,target建议设为ES2020以上,module设为ESNext,这样模板字面量类型和infer的行为最稳定。
常见做法是在package.json里加一个脚本:"test": "tsd",然后每道题写一个.test-d.ts文件,跑npm test就能看到哪些类型断言失败。失败信息会直接告诉你期望类型和实际类型的差异,比在编辑器里肉眼比对高效得多。
3.2 逐题验证:以 Pick 的重新实现为例
拿一道经典题练手:不用内置的Pick,自己实现一个MyPick<T, K>。题目要求从T中选取K指定的键,返回新类型。
// 实现 type MyPick<T, K extends keyof T> = { [P in K]: T[P]; }; // 测试文件 my-pick.test-d.ts import { expectType } from 'tsd'; interface Todo { title: string; description: string; completed: boolean; } type TodoPreview = MyPick<Todo, 'title' | 'completed'>; // 断言:TodoPreview 应该只有 title 和 completed expectType<{ title: string; completed: boolean }>({} as TodoPreview);逻辑说明:K extends keyof T约束了K必须是T的键的子集,这是类型安全的关键。[P in K]遍历K中的每个键,T[P]取出对应的值类型。测试文件里用expectType断言最终类型,如果实现有误,tsd会报错并指出差异。
参数调整:如果把K extends keyof T改成K extends string,就失去了键的约束,传入不存在的键也不会报错,这是常见的误用。我一般会先写约束再写实现,确保类型边界在编译期就卡住。
3.3 调试类型错误的三个实用手段
类型体操最头疼的是推导结果不符合预期,但报错信息往往只告诉你「类型不匹配」,不告诉你中间步骤哪里出了问题。我常用的排查手段有三个:
第一,用type Debug<T> = { [K in keyof T]: T[K] }把中间类型展开,看看实际推导出了什么。第二,把复杂类型拆成多个中间类型,逐个验证。第三,用// @ts-expect-error标记预期会报错的行,如果没报错说明类型约束太松。
// 调试示例:展开中间类型 type Debug<T> = { [K in keyof T]: T[K] }; type Step1<T> = T extends Promise<infer V> ? V : T; type Step2<T> = Step1<T> extends Array<infer E> ? E : Step1<T>; // 如果 Step2 结果不对,先用 Debug<Step1<...>> 看第一步推导 type Input = Promise<Array<number>>; type Mid = Debug<Step1<Input>>; // Array<number> type Final = Step2<Input>; // number逻辑说明:Debug把类型展开成普通对象形式,编辑器悬停时能看到具体结构。Step1和Step2把推导拆成两步,如果Final不对,先看Mid是否符合预期。参数Input换成实际要处理的类型即可。
注意:Debug对联合类型和函数类型效果有限,遇到这两种情况建议直接用expectType断言中间结果。另外,TypeScript 版本不同,某些边界行为的推导结果可能有差异,建议锁定typescript版本,避免升级后题目验证结果变化。
4. 避坑与排查:类型体操里那些让人摔键盘的瞬间
4.1 现象:条件类型在联合类型上结果不符合预期
原因:条件类型默认是分布式的,当T是联合类型时会自动分发。如果不想分发,需要用[T] extends [U]包裹。
解决:在条件类型两边加方括号,阻止分发行为。
// 会分发:'a' | 'b' 分别判断 type Distributed<T> = T extends string ? 'yes' : 'no'; type R1 = Distributed<'a' | 'b'>; // 'yes' | 'yes' = 'yes' // 不分发:整体判断 type NonDistributed<T> = [T] extends [string] ? 'yes' : 'no'; type R2 = NonDistributed<'a' | 'b'>; // 'yes'4.2 现象:infer 推导出 never 或 unknown
原因:infer的位置和约束不匹配,或者T不满足extends子句的结构要求。
解决:先用Debug展开T,确认它的实际结构,再调整infer的位置。如果T可能是never,在条件类型开头加[T] extends [never]判断。
4.3 现象:递归类型报「类型实例化过深」
原因:TypeScript 对递归深度有限制,默认大约 50 层,超过就报错。
解决:减少递归层数,或者用尾递归优化写法。常见做法是把累积结果放在额外参数里,避免每次递归都生成新类型。
// 可能超深的写法 type DeepReadonly<T> = { readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]; }; // 优化:加一个深度计数器 type DeepReadonlyWithLimit<T, Depth extends number = 5> = Depth extends 0 ? T : { readonly [K in keyof T]: T[K] extends object ? DeepReadonlyWithLimit<T[K], Prev<Depth>> : T[K] };4.4 现象:模板字面量类型在旧版本 TypeScript 上报错
原因:模板字面量类型是 TypeScript 4.1 引入的,低于这个版本不支持。
解决:升级typescript到 4.1 以上。如果项目无法升级,用字符串拼接的替代方案,但会失去类型推导能力。
4.5 现象:tsd 断言通过但编辑器里报错
原因:tsd使用的 TypeScript 版本和编辑器内置版本不一致。
解决:在package.json里锁定typescript版本,并确保编辑器的 TypeScript 版本设置为「使用工作区版本」。VS Code 里可以通过命令面板的「TypeScript: 选择 TypeScript 版本」来切换。
5. 进阶技巧:用类型测试驱动开发与性能取舍
5.1 把类型断言当成单元测试来写
这套资源最值得养成的习惯是:每写一个工具类型,先写测试文件,再写实现。tsd的expectType和expectError能覆盖大部分场景,前者验证类型相等,后者验证类型约束是否生效。
// 先写测试 import { expectType, expectError } from 'tsd'; type MyOmit<T, K extends keyof T> = { [P in Exclude<keyof T, K>]: T[P]; }; interface User { id: number; name: string; email: string; } // 正常场景 type UserWithoutEmail = MyOmit<User, 'email'>; expectType<{ id: number; name: string }>({} as UserWithoutEmail); // 错误场景:K 必须是 T 的键 expectError<MyOmit<User, 'nonexistent'>>();逻辑说明:expectError用来验证类型约束是否生效,如果MyOmit的K没有extends keyof T约束,这行就不会报错,tsd会提示测试失败。参数K的约束是类型安全的关键,去掉后虽然能编译,但失去了类型保护。
5.2 类型性能:什么时候该停下来
类型体操写多了容易上瘾,但复杂类型会拖慢编译速度。我一般用tsc --diagnostics看编译耗时,如果某个类型导致编译时间明显增加,就考虑简化。
| 类型复杂度 | 编译耗时参考 | 建议 |
|---|---|---|
| 简单映射类型 | 小于 1ms | 放心用 |
| 多层条件类型嵌套 | 1-5ms | 控制在 3 层以内 |
| 递归类型 | 5-20ms | 加深度限制 |
| 模板字面量联合展开 | 20ms 以上 | 考虑拆成多个类型 |
常见做法是:业务代码里用简单类型,复杂类型只在工具库或类型测试里用。如果发现编辑器提示变慢,先检查是不是某个类型推导卡住了,用Debug展开看看中间结果是不是爆炸式增长。
5.3 一个具体技巧:用类型断言做 API 响应校验
实际项目里,后端返回的 JSON 结构往往和前端定义的类型有偏差。我一般会写一个AssertType<T>工具类型,在开发阶段强制校验。
// 类型断言工具 type AssertType<T, U extends T> = U; // 使用:确保 API 响应符合预期类型 interface ApiResponse { code: number; data: { list: Array<{ id: number; name: string }>; }; } // 模拟后端返回 const response = { code: 200, data: { list: [{ id: 1, name: 'test' }], }, } as const; // 类型校验:如果 response 不符合 ApiResponse,这里会报错 type Check = AssertType<ApiResponse, typeof response>;逻辑说明:AssertType<T, U extends T>要求U必须是T的子类型,如果response的结构和ApiResponse不匹配,typeof response就不满足extends ApiResponse,编译期直接报错。参数T是期望类型,U是实际类型,顺序不能反。
注意:as const会把所有属性变成readonly,如果ApiResponse里没有readonly,需要手动去掉或者调整断言方式。我一般会在开发阶段用这个技巧,上线前把断言去掉,避免运行时开销。
从那以后我每次写工具类型都强制先写tsd测试文件,跑通了再合入主分支。类型体操这东西,翻车往往不是因为不会写,而是因为没验证。希望帮到你。
本文还有配套的精品资源,点击获取