这几年在团队里推进TypeScript落地,我手里攒了一套内部代号叫 t3code 的工程实践。它不是某个开源框架,也不是开箱即用的脚手架,更像是一组基于 TypeScript 3.x 时代的代码组织方式、类型设计约定和编译链路配置的沉淀。把它整理出来,是因为直到现在,很多项目里反复出现的类型安全、异步流程、依赖管理问题,用这套思路依然能快速给出可靠方案。
这篇文章适合正在用 TypeScript 写业务、但又觉得“类型写了不少,该踩的坑一个没少”的开发者,也适合准备把老项目从 JavaScript 迁移到 TypeScript 的团队参考。我会把 t3code 拆成几个层面:工程构建怎么配、类型系统怎么设计、异步业务怎么处理、装饰器和反射怎么用,以及我实际踩过的若干典型问题。内容偏实践,每一步都有配置和代码,可以直接落到自己的项目里试验。
1. T3Code到底解决什么问题——先看整体设计思路
1.1 标题背后的核心诉求
T3Code 这个代号里,T3 指 TypeScript 3.x 这条技术主线。之所以把它单独拎出来,是因为 3.x 这一代在类型能力上补齐了太多关键拼图:条件类型、infer 关键字、unknown 类型、可变元组、递归类型引用,这些都让“写类型”不再只是给变量标注一下那么简单,而是真正可以做类型层面的编程。但能力强了,门槛也上来了。团队里最常见的状态是:tsconfig 配置随手抄一份,类型定义写得很满,运行时错误照样层出不穷。T3Code 想解决的,就是用一套统一约定把 TypeScript 的类型能力和实际业务场景结合起来。
围绕 t3code 的核心关键词,可以拆出几个具体侧面:工程配置、类型设计、异步模式、装饰器与反射。这四个侧面不是孤立存在的。工程配置解决的是编译链路和模块系统的问题;类型设计解决的是数据模型和接口契约的信任问题;异步模式解决的是多接口协作和错误处理的问题;装饰器和反射则是 t3code 里比较重的一部分,用于实现轻量控制反转。把它们放在一条阅读线里,你会发现最终指向的是同一个问题的解法:让代码在写的时候就有足够的约束,在跑的时候又不被这些约束绊住。
1.2 为什么选 TypeScript 3.x 这一代作为基底
也许有人会问,现在 TypeScript 都到 5.x 了,还守着 3.x 的实践有什么意义?我的真实看法是,技术版本会迭代,但工程思想的适用期远比你想象的长。t3code 在设计时抓住了 3.x 提供但很多项目没有用好的三样东西:第一是 strict 模式下的完整空值检查,第二是条件类型加 infer 带来的类型推导能力,第三是装饰器与 emitDecoratorMetadata 的元数据能力。这三样在三代之后依然是 TypeScript 的核心底座,新版本只是在这个基础上做了性能和语法层面的增强。
也就是说,我现在回头看 t3code,仍然建议新项目在架构设计上沿用它的骨架。你完全可以用 TypeScript 5.x 的语法去写,但类型分层、仓库结构、装饰器注入这套骨架不会过时。文章中所有代码示例,兼容 TS 3.3 及以上版本,最新版本照样能直接用。理解了这一点,你再看后面的配置和代码,就不会觉得是在追忆一个老版本,而是看到一套可以长期复用的方法论。
2. 工程搭建与编译链路配置——T3Code的骨架长这样
2.1 tsconfig.json核心配置思路
t3code 的工程配置有一套严格的基线,我把它精简成下面这份。不要小看这些选项,它们直接影响类型检查的严格程度和最终产物的运行环境。
{ "compilerOptions": { "target": "ES2017", "module": "CommonJS", "moduleResolution": "Node", "lib": ["ES2017", "DOM"], "strict": true, "noImplicitAny": true, "strictNullChecks": true, "noUncheckedIndexedAccess": true, "esModuleInterop": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "declaration": true, "sourceMap": true, "outDir": "dist", "baseUrl": ".", "paths": { "@/*": ["src/*"] } }, "include": ["src"], "exclude": ["node_modules", "dist", "test"] }这里有几个点我不建议改。noUncheckedIndexedAccess 是很多人忽略的选项,打开之后数组和对象的索引访问会带上 undefined 判断,写业务时确实烦,但能挡掉一大批空指针。experimentalDecorators 和 emitDecoratorMetadata 是后面实现依赖注入的基础。如果你完全不打算用装饰器,可以把这两项关掉,但 t3code 的路子选的是用装饰器做控制反转,所以我会保持开启。
2.2 双构建链路处理方案
TypeScript 编译 CommonJS 产物之后,会遇到一个绕不开的问题:直接用 node 跑 jest 或直接在浏览器里加载,模块格式不一定匹配。t3code 的处理方式非常朴素:项目内不做复杂的构建编排,保留一条专门给 Node 用的编译链路,另一条给测试用。给测试的那条,我用 ts-jest 配合 presets 做内存编译,不实际产出 dist 文件,这样跑单测不需要每次先 build。给发布用的则是标准 tsc 编译,固定产出 CommonJS 加 d.ts。
你可能关心的一个问题:为什么不用 babel?我的经验是,babel 对装饰器语法的处理在早期版本存在版本差异风险,并且缺少真正的类型检查能力,它只会把类型抹掉。t3code 选择直接用 tsc 编译,虽然速度慢点,但保证了一个事实:编译出来的代码在类型层面和行为层面是一致的。这里给一条实操建议,如果项目大了之后编译速度变慢,优先用 tsc -w 增量编译来缓解,不要急着引入复杂的打包器。
2.3 路径别名与运行时对齐
配置里有 paths 把 @ 别名指向 src,这个配置在 TypeScript 侧很容易,但运行时会遇到问题。编译出来的 dist 目录里,require('@/xxx') 并不会自动解析到正确位置,必须告诉 Node 怎么处理。t3code 里最简单有效的方案是用 module-alias 包,或在 package.json 里维护一个 _moduleAliases 映射。
"_moduleAliases": { "@": "dist/src" }如果你的项目是用 ts-node 直接跑源码而不是先构建,那需要在 tsconfig-paths 里注册同名路径。我建议在项目入口文件的顶部显式调用,不要依赖环境变量:
import 'tsconfig-paths/register';路径别名这个小问题,项目一大人就很容易爆出来。我在多个项目里看到过测试代码 pass、运行时却报 module not found 的诡异现象,绝大多数就是路径解析两边没对齐。t3code 的规则很简单:源码一律用别名,测试和工具脚本一律不用,缺省就报错,反而省心。
3. 类型系统的核心设计——让类型成为业务约束
3.1 用字面量联合类型替代魔法字符串
t3code 里最基础的一条约定:业务状态、枚举值和操作类型的定义,绝对不允许裸字符串散落在业务代码里。但不是简单地用 enum,而是优先用字符串字面量联合类型。为什么?enum 在运行时会有反向映射和对象包装,字面量联合类型则能精确匹配实际传给接口的值,写起来也更接近数据本身。
以一个订单状态为例:
export type OrderStatus = | 'CREATED' | 'PAID' | 'SHIPPED' | 'COMPLETED' | 'CANCELLED'; export interface Order { id: string; status: OrderStatus; amount: number; paidAt?: Date; }这样做的最直接好处,是任何拿 Order 当参数的函数,如果想往里塞一个 'REFUNDED',编译阶段就会直接失败。相比在运行时靠 if 去判字符串,这个约束前置得太明显了。当业务状态需要聚合时,可以用映射类型自动派生状态组:
export type PayableStatus = Extract<OrderStatus, 'CREATED' | 'PAID'>;3.2 泛型约束与条件类型配合的接口契约
条件类型加 infer 是 t3code 类型设计的进阶玩法。很多业务场景里有“获取一个函数的返回类型”的需求,不了解 infer 的同事很容易写成 any,或者在接口里单独维护一份类型定义,导致两边同步出问题。正确做法是直接让编译器推断:
export type ReturnTypeOf<T extends (...args: any[]) => any> = T extends (...args: any[]) => infer R ? R : never; type CreateOrderResult = ReturnTypeOf<typeof createOrder>;这只是开胃菜。更实用的场景是接口 Response 结构的统一处理。假设你的后端约定所有接口返回 unified envelope,t3code 定义了一个条件类型,在拿到业务数据结构的同时保留错误分支的判断能力:
export interface ApiResponse<T> { code: number; message: string; data: T; } export type UnwrapResponse<T extends Promise<ApiResponse<any>>> = T extends Promise<ApiResponse<infer D>> ? D : never;这样定义之后,调用方在 async 函数里拿到的 data 类型已经精确到具体业务结构,不再需要手工断言。
3.3 类型守卫与断言的最佳实践
类型守卫是很多 TypeScript 项目里被低估的能力。t3code 约定,所有从外部数据源解析出来的字段,进入内部模型之前必须过一道类型守卫。举个例子,后端返回的字段有可能是 null,也可能直接缺字段,前端如果只靠 any 或强制断言,后面就是一堆运行时错误。
export function isOrder(value: unknown): value is Order { if (!value || typeof value !== 'object') return false; const candidate = value as Record<string, unknown>; return ( typeof candidate.id === 'string' && typeof candidate.status === 'string' && typeof candidate.amount === 'number' ); }类型守卫看起来像运行时逻辑,但它更大的价值是帮助编译器缩小类型范围。用 isOrder 判断之后,后续代码里 value 自动变成 Order,不需要再二次断言。这条约定对接口层尤其重要:网络请求返回的数据永远把它当 unknown 看待,先守卫再使用,是 t3code 的硬性要求。
4. 异步流程与多接口业务——T3Code的实战写法
4.1 用Promise组合替代回调嵌套
异步编程在业务项目里出现频率很高。虽然现在 async/await 已经是主流,但我观察到很多项目仍在使用随意嵌套的写法,把 Promise.all 的并行能力丢在一旁。t3code 在异步组合上有几个固定范式,核心原则是先并行后串行,减少等待时间。
遇到“同时获取用户信息和订单列表,然后组装详情”这样的需求,我推荐这样写:
async function loadUserDetail(userId: string) { const [user, orders] = await Promise.all([ fetchUser(userId), fetchOrdersByUser(userId), ]); if (!user || !orders) { throw new Error('loadUserDetail failed: incomplete data'); } const detail = await assembleDetail(user, orders); return detail; }这段代码真正重要的是 Promise.all 的写法。两条接口之间没有依赖关系,并行发起可以把耗时从两次网络往返压缩成一次。t3code 里有一条约定:连续 await 两个以上互不依赖的请求,会被代码评审阶段打回。
4.2 尽量把异步错误转换成类型分支
try/catch 是处理异常的基本手段,但 t3code 更倾向于把可能失败的异步结果建模成联合类型。这不是说不用 try/catch,而是把错误处理的职责明确到一个专门函数里,业务侧拿到的是一个判别联合,可以通过 switch 拿到可穷尽的分支判断。
以一个登录服务为例:
export type LoginResult = | { ok: true; token: string } | { ok: false; reason: 'INVALID_PASSWORD' | 'USER_NOT_FOUND' | 'NETWORK_ERROR' }; export async function login(username: string, password: string): Promise<LoginResult> { try { const res = await fetch('/login', { method: 'POST', body: JSON.stringify({ username, password }), }); if (res.status === 401) { const error = await res.json(); return { ok: false, reason: error.reason }; } const data = await res.json(); return { ok: true, token: data.token }; } catch { return { ok: false, reason: 'NETWORK_ERROR' }; } }拿返回值的地方就不用再 throw 满天飞,直接 switch:
const result = await login('admin', '123456'); if (result.ok) { localStorage.setItem('token', result.token); } else { showErrorMessage(result.reason); }这个模式的核心收益,是把错误处理从流程里拎出来,变成类型系统可以推导的状态。少了隐式的异常传递,调试时只需要看返回值的 reason 就知道发生了什么。
4.3 并发请求上限控制
Promise.all 适合小批量并发,但如果同时要拉取几十个详情接口,直接 Promise.all 会把连接数打爆。t3code 里封装了一个简单并发池,控制同时进行的任务个数,网上很多并发池实现,这里给出一个经过验证的版本:
export async function runWithConcurrency<T>( tasks: (() => Promise<T>)[], limit: number ): Promise<T[]> { const results: T[] = []; const queue = [...tasks]; async function worker() { while (queue.length > 0) { const task = queue.shift(); if (!task) break; const result = await task(); results.push(result); } } const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => worker()); await Promise.all(workers); return results; }用法也直接:
const tasks = ids.map((id) => () => fetchDetail(id)); const details = await runWithConcurrency(tasks, 5);limit 选多大?我一般根据接口平均耗时和总任务量来算,目标是让整体耗时落在能接受的范围,同时不会触发服务端的限流。大量场景下 5 到 8 是比较稳妥的,不是越大越好。
5. 装饰器与反射能力——T3Code里的控制反转尝试
5.1 手写一个轻量依赖注入
t3code 里最有意思的部分是用装饰器实现控制反转。TypeScript 的 experimentalDecorators 加 emitDecoratorMetadata 开启后,编译阶段会自动把被装饰类的参数类型以元数据形式记录到 Reflect 对象里。利用这一点,可以摆脱传统的手工依赖传递。
一个容器的最小实现可以是这样:
interface Type<T = any> { new (...args: any[]): T; } const container = new Map<string, Type>(); export function Injectable(): ClassDecorator { return (target) => { container.set(target.name, target as Type); }; } export function createInstance<T>(target: Type<T>): T { const paramTypes = Reflect.getMetadata('design:paramtypes', target); if (!paramTypes) { return new target(); } const dependencies = paramTypes.map((paramType: Type) => { const provider = container.get(paramType.name); if (!provider) { throw new Error(`Dependency ${paramType.name} is not registered`); } return createInstance(provider); }); return new target(...dependencies); }用起来长这样:
@Injectable() export class UserRepository { findById(id: string): User { return ...; } } @Injectable() export class UserService { constructor(private repo: UserRepository) {} async getUser(id: string) { return this.repo.findById(id); } } const userService = createInstance(UserService);这里我要强调一个问题:Reflect.getMetadata 读取的 design:paramtypes,只有在构造函数参数是类型引用时才有用。如果参数写着 any、接口或者联合类型,就拿不到正确的元数据。所以 t3code 对依赖注入有个附加约定:被注入的类型必须是可以被 new 的具体类,接口注入不支持,这样就避免你踩进反射设计的死角。
5.2 emitDecoratorMetadata背后的原理引申
很多人可能好奇,设计元数据到底是怎么跑起来的。TypeScript 会为使用装饰器的类生成一行辅助代码,类似这种效果:
__decorate([ Injectable(), __metadata("design:paramtypes", [UserRepository]) ], UserService);也就是说,装饰器函数参数里多了一段元数据。运行时通过 require('reflect-metadata') 在全局挂载 Reflect.defineMetadata 和 Reflect.getMetadata,事情才真正成立。有一个细节必须注意:reflect-metadata 必须在入口文件最早的位置加载,否则你会在运行时报出 Reflect.getMetadata 不存在之类的错误。
如果你决定在自己项目里用这套方案,我建议先小范围试,比如只对 Service 层注入 Repository,不要一上来就全面铺开。等跑通了,再考虑是否引入更成熟的 DI 库。毕竟 t3code 的核心价值不是制造依赖,而是让你以最小成本理解这种模式。
6. 常见问题与排查技巧实录
6.1 类型推断被“折弯”的几种场景
我在项目推进中反复遇到过类型收窄失效的情况。最典型的场景是,变量被赋给另一个变量后再做判断,TypeScript 会丢失部分收窄信息。比如:
let data: string | null = getData(); const temp = data; if (temp === null) { // 这里 data 并不会收窄为 null }这背后是控制流分析的隔离原则。t3code 里的解法非常直接:判断时直接用原始变量,不要通过中间变量做间接推断;实在需要中间层函数,就显式注明联合类型。
另一类问题是条件类型里泛型默认不展开,导致 Readonly 或者是 Nullable 判断失效。遇到这种情况,t3code 的排查思路是先准备几个平铺的测试类型,确认条件分支输出的是哪个分支,再用分布式条件类型来激活。记住一个原则:泛型默认不展开时,加上裸类型参数可以触发分布求值。
6.2 装饰器依赖注入的三类运行时报错
装饰器方案最常见的报错有这几类:
第一类,Reflect.getMetadata 为空。原因很直接:reflect-metadata 没有在入口引入。检查入口文件的头部,确保它是第一个被加载的 import。
第二类,design:paramtypes 拿到的 undefined。通常是装饰器写在了方法上而不是类上,或者类的构造函数参数存在 any 类型。解决方法是把所有可注入依赖改成具体类引用。
第三类,循环依赖。A 依赖 B,B 也依赖 A,这个时候容器的构造会进入无法终止的递归直到栈溢出。t3code 的规避方式是把循环依赖中除了核心之外的那个依赖提升为懒加载,或者重新划分职责边界,尽量让依赖图保持单向。
6.3 编译产物与源码调试对齐的细节
用过 ts 开发 Node 项目的人一定有过这种经历:报错堆栈指向 dist 目录的某一行,看起来和在 src 里写的东西对不上,排查效率极低。使用 sourceMap 配置后,大多数人以为问题解决了,其实还差一步:Node 运行时默认并不会加载 source-map-support。你要么在入口引入这个库:
import 'source-map-support/register';要么把 sourceMap 产物和源码一起部署,让调试器自动识别。t3code 里两个方案都做了,入口注册 source-map-support,辅助生产堆栈定位,本地调试利用 IDE 的 TypeScript Debug Session,直接对接 src 源码断点,反而就不太依赖 map 文件了。
6.4 常见问题速查表
| 问题场景 | 可能原因 | 解决方式 |
|---|---|---|
| 类型收窄在赋值后失效 | 控制流分析隔离变量 | 直接用原始变量判断 |
| 联合类型的条件类型只返回一边 | 泛型未触发分布式求值 | 裸类型参数包裹 |
| require('@/') 运行时报错 | 路径别名未注册 | 添加 module-alias 或 tsconfig-paths |
| Reflect.getMetadata 不存在 | 缺少 reflect-metadata 导入 | 入口文件最先 import |
| decorator 参数类型拿不到 | paramtypes 是接口或 any | 改成具体类依赖 |
| 多个接口串行 await 太慢 | 无依赖被串行化 | 使用 Promise.all 或并发池 |
| TS 编译产物堆栈指向 dist | 缺少 source-map-support | 入口注册库或使用调试会话 |
这张表基本涵盖了 t3code 落地过程中出现频率最高的七个问题。真到排查时,对照症状定位,多数情况能在分钟级解决。
我个人在实际操作里有个体会:t3code 这套骨架最大的价值,不是某一个高级类型技巧多惊艳,也不是装饰器注入多炫酷,而是它逼着你在一开始就想清楚依赖关系和数据边界。很多项目 TypeScript 用得很痛苦,是因为类型定义只是把 any 换成了更长的 any,缺少真正的约束。如果你也遇到这种失去信心的情况,建议从 tsconfig 的 strict 和 noUncheckedIndexedAccess 做起,重建一把类型安全的防线。
最后再分享一个小技巧:把 t3code 的编译链路由 tsc 切换到更快的工具是可以的,但切换时一定要保留单独的类型检查流程。我见过团队为了编译速度放弃 tsc,结果类型错误全变运行时错误。快不是目的,稳才是。你可以把类型检查和构建分开,用 tsc --noEmit 做持续检查,构建产物用其他工具生成,两头兼顾。