2024 年春节后我们团队接了一个吃力不讨好的任务:把公司主站后台一个跑了七年的 JavaScript 项目(约 5 万行业务代码 + 1.2 万行测试)全量迁到 TypeScript。这个项目是 Node.js 14 + Express + MongoDB,前后端不分离的老古董,常年由不同的 5 任开发交接,代码风格混乱,类型一塌糊涂,光是"一个函数有 6 种调用方式"这种场景就遍地都是。立项时管理层给的预期是"30 天搞定",我们做了详细评估之后报上去 90 天,实际跑下来用了 102 天才正式收口。这篇把这次迁移的完整路线图、踩过的 15 个真实坑、放弃过的两条歧路、以及最后沉淀出来的"渐进迁移 SOP"全部写一遍,希望正在或者即将做 JS→TS 迁移的团队能少走些弯路。
项目背景:为什么决定迁
这个项目的痛苦在于"看起来能跑,实际处处雷"。每周平均会出现两到三次生产事故,根因大约一半是"传错参数类型"——比如把 string 类型的订单号传成了 number,或者把可选字段当成必选用了。Bug 追踪系统里这种"低级类型错误"占了全年事故的 38%,我们尝试过加 ESLint 规则、加运行时 schema 校验(用 joi),效果都不理想:ESLint 规则太死板,joi 校验只能在边界生效。讨论了几个月,最终拍板上 TypeScript,目标不是花哨的类型炫技,而是把"传参类型错"这一类事故彻底消灭。
| 阶段 | 时间 | 关键产出 |
|---|---|---|
| 立项 + 调研 | D1-D7 | 评估报告、迁移路线图、SOP 草案 |
| 基础设施搭建 | D8-D14 | tsconfig、构建链、CI 流水线 |
| allowJs 兼容期 | D15-D35 | JS / TS 混存, 双轨编译跑通 |
| 核心模块迁移 | D36-D70 | 2.8 万行核心业务代码切 .ts |
| 边缘模块迁移 | D71-D90 | 剩余 2.2 万行业务 + 1.2 万行测试 |
| 开 strict 收口 | D91-D102 | strict: true 全开, noImplicitAny 强制 |
第一周做的事:决定路线
迁移启动后的第一周完全没碰代码,只在做规划。我们花了五天时间评估了三条路线:全量重写(估计需要 8 个月,直接砍掉)、用 JSDoc 加类型注解(估计 60 天,但缺乏强约束)、用 allowJs 渐进迁移到原生 TS(估计 90 天,可控性最强)。最终选 allowJs 渐进迁移,核心理由是可以随时回滚、风险可控、新老代码混跑、不影响业务发布节奏。这条路线后来被证明是对的,但实际跑下来仍有不少 allowJs 模式的坑(后面会讲)。
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"allowJs": true, // 允许 .js 文件参与编译
"checkJs": false, // 但暂时不检查 .js 的类型 (后期再开)
"esModuleInterop": true,
"resolveJsonModule": true,
"strict": false, // 先不开 strict, 渐进改
"noImplicitAny": false, // 先关 noImplicitAny, 允许隐式 any
"skipLibCheck": true, // 跳过 node_modules 的类型检查, 否则编译慢
"sourceMap": true,
"declaration": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.js"]
}
这份 tsconfig 是迁移启动版本,有几个关键决策。第一,allowJs + checkJs=false:允许 JS 文件参与编译但不做类型检查,先把构建链跑通再说。第二,strict: false:先让代码能编过,不要一上来就被几千个类型错误淹没。第三,skipLibCheck: true:不检查 node_modules 里的类型,否则我们这种几百个 npm 依赖的老项目能给你编出几万个错误,根本没法干活。这三个开关后期都要逐步收紧,但启动期必须先松后紧。
第一个真实大坑:CommonJS 和 ESM 混乱
构建链跑通后做的第一件事是 export/import 的兼容。原项目是清一色 const x = require('x'),我们引入 TypeScript 默认用 import x from 'x',看起来兼容,但实际有大量库的默认导出形式不一致,埋了一堆雷。最经典的例子是 moment.js:在 CommonJS 下是 const moment = require('moment'),在 TS 里如果直接写 import moment from 'moment' 配置不对就会拿到 undefined。我们花了整整两天才把这块兼容问题理清楚。
// 反模式: esModuleInterop 没开时, 这种写法会拿到 undefined
import moment from 'moment';
moment().format(); // TypeError: moment is not a function
// 正确做法 1: 开 esModuleInterop (推荐)
// tsconfig.json: "esModuleInterop": true
import moment from 'moment'; // 现在能正常工作
// 正确做法 2 (不开 esModuleInterop 时的兼容写法)
import * as moment from 'moment';
(moment as any)().format(); // 丑但可用
// 正确做法 3 (默认导出真的没有时, 比如某些工具库)
import { format, parseISO } from 'date-fns';
const d = parseISO('2024-01-01');
format(d, 'yyyy-MM-dd');
// 老代码兼容: 仍然可以用 require 救急
const legacyLib = require('some-untyped-lib');
// 但要打 @ts-ignore 或写 .d.ts
esModuleInterop 这个选项的作用是模拟 Babel 的默认导出兼容行为,几乎所有现代 TS 项目都应该开。我们打开它之后,大约 90% 的 import 问题自动消失了,剩下的 10% 是一些没有类型定义文件(.d.ts)的老 npm 包,需要手动写声明或者 install @types/xxx。这一块工作量比预期大得多,有些冷门包根本没人写过 @types,我们最后给 8 个内部库写了 .d.ts,提交到了 DefinitelyTyped 仓库。
渐进迁移的关键决策:文件粒度
迁移启动后,第一个具体动作是按文件粒度逐个改 .js → .ts。我们试过两种粒度:函数粒度(在同一个 .js 文件里逐个函数加 JSDoc 类型注解)和文件粒度(整个文件一次性改成 .ts)。前者看起来"温柔",但实际操作下来痛苦得多,因为 JSDoc 的表达力远不如 TS,而且要管理混合状态。文件粒度虽然单次改动大,但每个文件迁完就是一个干净的小胜利,士气也好维持。
第二个大坑:any 满天飞
开始迁移后第三周,我们发现一个让人尴尬的现象:为了让代码"快速能编过",team 里大量使用了 any 兜底,有些文件里 any 出现的频率比 string 还高。这种迁移其实是"假迁",代码看起来是 .ts 了,但类型保障基本没有,bug 该出还是出。我们紧急叫停,定了一个规矩:any 必须有理由,要写注释 @ts-expect-error 或者 // FIXME(any): xxx。然后用 eslint 规则 @typescript-eslint/no-explicit-any 当 warning 级别,在 CI 里输出 any 数量趋势,每周复盘。
// 反模式: 偷懒用 any 兜底
function processOrder(order: any) {
return order.items.map((item: any) => item.price * item.qty);
}
// 修法 1: 定义 interface
interface OrderItem {
price: number;
qty: number;
sku: string;
}
interface Order {
id: string;
userId: string;
items: OrderItem[];
createdAt: Date;
}
function processOrder(order: Order): number[] {
return order.items.map(item => item.price * item.qty);
}
// 修法 2: 临时不知道类型, 用 unknown + 类型守卫 (比 any 安全)
function parsePayload(raw: unknown): Order {
if (typeof raw !== 'object' || raw === null) {
throw new Error('Invalid payload');
}
const obj = raw as Record<string, unknown>;
if (typeof obj.id !== 'string') {
throw new Error('Invalid order id');
}
// ... 一系列守卫
return obj as unknown as Order;
}
// 修法 3: 用 zod 等 runtime schema 库, 一行得到类型 + 校验
import { z } from 'zod';
const OrderItemSchema = z.object({
price: z.number(),
qty: z.number().int().positive(),
sku: z.string(),
});
const OrderSchema = z.object({
id: z.string(),
userId: z.string(),
items: z.array(OrderItemSchema),
createdAt: z.date(),
});
type Order = z.infer<typeof OrderSchema>; // 类型自动推出来
const validated = OrderSchema.parse(rawData); // 同时做了运行时校验
引入 zod 之后我们迁移效率明显提升,因为 zod 同时解决了"运行时校验"和"类型定义"两件事,以前用 joi 写校验、用 interface 写类型,两套人维护,经常对不上。zod 之后 schema 就是单一真理来源,改一处全改了。整个项目大约 80% 的 API 边界都改用了 zod,代码量净减少了 1500 行(因为去掉了一堆重复的类型 + 校验代码)。
第三个大坑:Express 中间件类型签名
Express 是 2010 年代的库,类型支持一直不够好,尤其是中间件的 req/res 类型扩展。原项目里有大量中间件给 req 加自定义字段(req.user、req.session、req.tenantId 之类),JS 时代随便加,TS 一下子全报错。正确做法是用 declaration merging 扩展 Express.Request 接口,但这个特性不熟悉的同学经常写错位置导致全局污染。
// types/express.d.ts (放在 tsconfig include 范围内)
import 'express';
declare global {
namespace Express {
interface Request {
user?: {
id: string;
email: string;
roles: string[];
};
tenantId?: string;
requestId: string;
}
}
}
// 中间件里加字段
import { Request, Response, NextFunction } from 'express';
export function authMiddleware(
req: Request,
res: Response,
next: NextFunction
): void {
// 解析 token, 拿到 user
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
res.status(401).json({ error: 'no token' });
return;
}
const payload = verifyToken(token);
req.user = payload; // ← 现在这里有类型, IDE 自动补全
req.tenantId = payload.tenantId;
next();
}
// 控制器里用
app.get('/me', (req, res) => {
if (!req.user) {
return res.status(401).json({ error: 'no user' });
}
res.json({ id: req.user.id, email: req.user.email }); // 自动补全
});
这个声明文件位置至关重要。我们一开始放在 src/types/ 下,工作正常;后来重构时不小心移到了 src/typings/,但 tsconfig 的 include 没改,全局扩展失效,所有控制器里 req.user 都报错。这种"位置敏感"的全局扩展很容易踩,建议在项目 README 里专门写一段说明,告诉新人这些 .d.ts 文件放哪、为什么。
第四个大坑:循环依赖
老 JS 项目几乎一定有循环依赖,只是 JS 运行时对循环依赖很宽容(可能拿到 undefined,但不会报错),所以一直没人意识到问题。改成 TS 之后循环依赖会引发奇怪的类型错误,有时候是"找不到类型",有时候是"类型变成 never",非常难排查。我们用 madge 这个工具扫了一遍,扫出了 47 处循环依赖,全部修掉花了一周。
# 安装 madge
npm install -g madge
# 扫描循环依赖 (--circular 只输出环, --extensions 指定要扫的后缀)
madge --circular --extensions ts,js src/
# 输出示例:
# 1) src/models/user.ts > src/models/order.ts > src/models/user.ts
# 2) src/services/payment.ts > src/services/order.ts > src/services/payment.ts
# 47 circular dependencies found.
# 生成依赖图 (输出为 svg, 直观看依赖关系)
madge --image deps.svg --extensions ts src/
# CI 里加入循环依赖检查, 防止新增
madge --circular --extensions ts,js src/ && echo "no circular deps"
修循环依赖的标准手法是抽出共同依赖到一个独立模块。比如 user.ts 和 order.ts 互相依赖,通常是因为它们都引用了一些共享类型,把这些类型抽到 types/shared.ts,两边都引这个新模块,环就破了。少数情况下确实需要双向依赖,比如循环引用的数据结构,这时候要用接口隔离(在一个文件里只引另一个文件的接口,而不是具体实现),让 TS 编译时只看到类型签名。
第五个大坑:第三方库的 @types 滞后或冲突
npm 生态里有些库有官方类型(包内自带 .d.ts),有些靠 DefinitelyTyped 社区维护(@types/xxx),还有少数没有任何类型。这三种情况要处理的方式不一样,而且经常出现 @types 版本滞后(比如库已经升到 5.0,@types 还停留在 4.x),导致类型签名对不上。我们项目里有过两个真实案例:@types/express 比 express 主包慢一个大版本,导致某些 API 类型不对;@types/lodash 包巨大(几兆),拖慢编译速度,我们最后改用 lodash-es 自带类型。
# 查一个库有没有官方类型
npm view express types # 自带类型
npm view express typings # 旧字段, 兼容
# 查 DefinitelyTyped 是否有 @types
npm view @types/some-lib version
# 强制锁定 @types 版本和主包匹配 (package.json)
{
"dependencies": {
"express": "4.19.2"
},
"devDependencies": {
"@types/express": "4.17.21"
}
}
# 极少数情况, 自己写 .d.ts
# src/types/legacy-lib.d.ts:
declare module 'legacy-lib' {
export function doSomething(input: string): number;
export const VERSION: string;
}
# 写完之后 import 就有类型了
import { doSomething } from 'legacy-lib';
这一块工作非常琐碎,我们专门安排一个同学每周清一次依赖,看看有没有 @types 滞后、有没有重复的类型定义、有没有可以替换的方案。三个月清下来,package.json 里的 devDependencies 从 142 个降到 88 个,体感清爽了不少,CI 拉依赖也快了 30%。
开 strict 模式:最后一公里最痛苦
所有 .js 改完 .ts 之后,真正的硬仗才开始:把 tsconfig 里的 strict 选项一个一个打开。strict 实际上是 8 个独立选项的总和(strictNullChecks、noImplicitAny、strictFunctionTypes、strictBindCallApply、strictPropertyInitialization、noImplicitThis、useUnknownInCatchVariables、alwaysStrict),我们的策略是一个一个开,每开一个就修完所有报错再开下一个,避免一次性面对几千个错误。
{
"compilerOptions": {
// 阶段 1 (第 6-7 周): 先开这两个, 影响最广
"noImplicitAny": true,
"strictNullChecks": true,
// 阶段 2 (第 8-9 周): 函数和绑定相关
"strictFunctionTypes": true,
"strictBindCallApply": true,
// 阶段 3 (第 10-11 周): 类相关
"strictPropertyInitialization": true,
"noImplicitThis": true,
// 阶段 4 (第 12 周): catch 和最后的边角
"useUnknownInCatchVariables": true,
"alwaysStrict": true,
// 等价于:
// "strict": true,
// 同步开启这些, 进一步收紧:
"noImplicitReturns": true, // 函数所有路径必须返回
"noFallthroughCasesInSwitch": true, // switch case 必须 break
"noUncheckedIndexedAccess": true, // arr[i] 返回 T | undefined
"exactOptionalPropertyTypes": true, // 可选字段不能显式赋 undefined
"noPropertyAccessFromIndexSignature": true
}
}
这八个 strict 选项里,strictNullChecks 是最痛苦的,因为老代码里大量使用 null 和 undefined 不加区分。打开之后我们修了大约 1800 处报错,主要模式是"参数可能为空但调用方没处理"。修法基本是三选一:加 if 守卫、用可选链 ?.、或者从源头就保证非空(比如函数签名改成必传)。这一块如果你的项目还没开 strictNullChecks,我强烈建议尽早开,任何 TS 项目不开这个等于自废武功。
四种 TS 迁移路线的真实对比
| 路线 | 预估周期 | 风险 | 实际产出 | 推荐场景 |
|---|---|---|---|---|
| 全量重写 | 6-12 个月 | 极高 | 代码很干净但业务可能错 | 3000 行以内小项目 |
| JSDoc 渐进 | 1-2 个月 | 低 | 类型表达力差, 易回退 | 过渡过渡, 不推荐长期 |
| allowJs 渐进 | 2-4 个月 | 中 | 可控可逐步收紧 | 大多数中型项目首选 |
| 分模块独立迁 | 3-6 个月 | 中低 | 新模块用 TS, 老模块保留 | monorepo 或微服务架构 |
CI 流水线的迁移配套
代码改造的同时,CI 也要跟上。我们的 CI 在迁移期间分了三道闸:第一道是 tsc --noEmit 跑类型检查(初期允许失败,逐步收紧);第二道是 ESLint 跑代码规范;第三道是 jest 跑单元测试 + 集成测试。这三道都通过才能合并,任何一道失败 block 合并。
# .github/workflows/ci.yml (节选)
name: CI
on:
pull_request:
branches: [main, develop]
jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: npx tsc --noEmit
- run: npx tsc --noEmit -p tsconfig.test.json
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: npx eslint --max-warnings 0 'src/**/*.{ts,js}'
- run: npx madge --circular --extensions ts,js src/
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: npx jest --coverage --maxWorkers=2
- uses: codecov/codecov-action@v4
这里有个细节:--max-warnings 0 是 ESLint 把 warning 当成 error 处理,适合"零警告"政策。我们一开始没开这个,结果 warning 越积越多,后来不得不专门花一周清掉所有 warning,再开 --max-warnings 0 防止反复。任何团队都应该坚持"零警告",一旦开放警告积累,以后清理成本是线性增长的。
迁移过程中放弃过的两条歧路
第一条歧路是用 ts-migrate 工具自动迁移。Airbnb 出过一个 ts-migrate 自动化迁移工具,理论上能自动把 .js 改成 .ts、自动加 any、自动跑 codemod。我们在第二周试了一下,在前 10 个文件上效果还行,但越往后越乱:工具加的 any 太多,某些复杂类型推断完全错,而且生成的代码风格和团队约定不符,后期清理成本比手动迁还大。我们最后放弃了这条路,改回纯手工。
第二条歧路是同时升级 Node.js 大版本。原项目跑在 Node 14,我们一度想"既然要大改,干脆 Node 也升到 20"。但很快发现 Node 升级会引入一堆兼容性问题(某些老依赖在 Node 18+ 跑不通,某些 native module 要重编译),和 TS 迁移叠加在一起复杂度爆炸。我们在第三周果断退回,只做 TS 迁移,Node 升级单独立项。这种"一次只做一件事"的纪律救了我们好几次。
第三条差点走的歧路是顺手切到 Deno 或 Bun。Deno 原生支持 TypeScript 不需要 tsc,Bun 启动飞快又自带打包,在内部分享会上有同事提出"既然都要大改,干脆运行时也换了"。我们认真评估了两天,结论是生态兼容性远没有想象中好:Deno 对 npm 兼容是后期才加的,我们用的 Express 中间件生态有一半在 Deno 上跑不顺;Bun 对 native module 的支持还在打磨期,我们依赖的 mongodb driver 有偶发崩溃。最后决定继续 Node + tsc,等 Deno / Bun 在生态成熟两到三年再考虑。这种"新技术诱惑"在大改造里特别危险,坚持只做必要的事,把"想做的事"延后,是大型项目能成功收口的关键。
真实收益:90 天后的数据
| 指标 | 迁移前 (JS) | 迁移后 (TS) | 变化 |
|---|---|---|---|
| 每月生产事故 | 8-12 次 | 2-3 次 | 降 70% |
| 类型相关事故占比 | 38% | 4% | 降 90% |
| 新人上手时间 | 3-4 周 | 1-2 周 | 降 50% |
| code review 时间 | 1.5 小时/PR | 40 分钟/PR | 降 55% |
| 构建时间 | 22s (webpack) | 35s (tsc + webpack) | 慢 60% |
| IDE 自动补全可用率 | 30% | 95% | 大幅提升 |
| 团队满意度 | 5/10 | 9/10 | 显著改善 |
这里要特别说一下"构建时间慢了 60%"这一项。tsc 编译本身确实比 babel + JS 慢,而且 strict 模式下类型检查耗时也明显。我们后来用 swc / esbuild 替换了一部分构建链,把构建时间压回了 18s(比迁移前还快),但类型检查仍然需要 tsc,这一段时间无法砍掉。这个权衡是"类型安全 vs 构建速度"的经典选择,99% 的团队应该选类型安全,构建速度的损失完全可以用增量编译、watch 模式来缓解。
两个让我们后悔没早做的优化
迁完之后回头看,有两个 TS 高级特性我们后悔没在第一天就引入,后期补做时已经积累了不少历史包袱要清。第一个是 Branded Types(品牌类型 / nominal typing),用来给"看起来都是 string 但语义不同"的 ID 加区分,防止把 UserId 当成 OrderId 用。我们历史上至少有 4 次生产事故是这种 ID 错传引起的,任何静态类型检查都救不了,因为它们都是 string。Branded Types 用一个 trick 让 TS 在类型层面区分两个 string,运行时零成本。
// 反模式: 用 type 别名, 等于没区分
type UserId = string;
type OrderId = string;
function getOrder(id: OrderId) { /* ... */ }
const userId: UserId = 'u_123';
getOrder(userId); // ← 编译通过, 但实际是 bug
// 修法: Branded Types
declare const __brand: unique symbol;
type Brand<T, B> = T & { readonly [__brand]: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
// 构造函数 (运行时只是 cast, 零成本)
const UserId = (s: string) => s as UserId;
const OrderId = (s: string) => s as OrderId;
function getOrder(id: OrderId) { /* ... */ }
const userId = UserId('u_123');
getOrder(userId); // ← 编译错误! Type 'UserId' is not assignable to 'OrderId'
getOrder(OrderId('o_456')); // ← OK
// 升级版: 配合 zod 同时做运行时校验
import { z } from 'zod';
const UserIdSchema = z.string().brand<'UserId'>();
type UserId = z.infer<typeof UserIdSchema>;
const uid = UserIdSchema.parse(req.params.userId); // 类型 + 校验 一行搞定
引入 Branded Types 之后,我们把所有"业务 ID"的定义都改成了 branded,大约动了 230 个地方,但效果立竿见影:之后半年的事故里再没有一例 ID 错传,这种"用类型系统消灭整类 bug"的体感非常爽。Branded 也可以用来区分单位(Meters / Feet)、时区(UTCTime / LocalTime)、清洁状态(SanitizedHtml / RawHtml),思路完全一样。
第二个后悔的是 noUncheckedIndexedAccess 没在第一天就开。这个选项强制 arr[i] 和 obj[key] 的返回类型变成 T | undefined,因为下标访问本来就可能拿不到值。这一改让代码里冒出几百个"忘记处理 undefined"的隐藏 bug,虽然修起来烦,但每一个被修掉的都是真实存在的潜在崩溃。我们后期补开时修了 380 处报错,其中有 17 处是已经在生产引发过偶发故障的真 bug。强烈建议任何新 TS 项目第一天就开 noUncheckedIndexedAccess,代价远小于收益。
团队立的 10 条 TS 工程纪律
- any 必须有理由:每一处 any 必须有注释 // FIXME(any): xxx 或 @ts-expect-error,CI 跟踪 any 数量趋势,只能减不能增。
- strict 全开不打折:strict: true + noUncheckedIndexedAccess + exactOptionalPropertyTypes 是标配,新项目从一开始就开。
- 禁止 // @ts-ignore:用 @ts-expect-error 替代,后者在错误消失时会报错,提醒你删掉这一行。
- 边界用 zod 校验:HTTP 入口、消息队列消费者、数据库读取等所有外部数据入口必须 zod 校验,内部代码相信类型。
- 禁止隐式 export:所有 export 必须显式,不允许
export * from,容易造成意外的 API 泄露。 - 禁止 enum,改用 const + as const:enum 在 TS 里有运行时开销且和 tree-shaking 不友好,用
const X = { ... } as const替代。 - 类型抽到独立文件:每个模块的类型定义放
types.ts,业务逻辑放index.ts,清晰分层。 - 泛型不超过 3 层:超过 3 层泛型嵌套必须 PR 评审解释,过度泛型化是技术债的开端。
- 禁止 namespace:用 ES module 替代 TS namespace,后者是历史遗留特性。
- 测试也要 TS:测试代码也用 .ts,享受同样的类型安全,而不是 "测试无所谓" 的态度。
给同行的几条建议
第一条建议是不要低估迁移工作量。我们 5 万行项目原计划 30 天,实际 102 天,几乎是预估的 3.4 倍。任何 JS→TS 迁移项目都应该按"预估 × 3"的余量来规划,千万别给管理层报激进时间表,做不完只会自己背锅。
第二条建议是渐进式迁移是唯一选择。除非项目特别小(几千行以内),否则不要尝试"全量重写"或"暂停业务集中迁移"。业务永远不会停,迁移必须能和日常开发并行,allowJs 模式就是为此而生。
第三条建议是strict 必须早开,但要分步开。strict 是 TS 的灵魂,不开 strict 等于白用 TS。但一次性开八个选项会被几千个错误淹没,正确做法是按本文的阶段表一步步开,每步只面对一类错误。
第四条建议是类型不是终点,是工具。TS 不能解决业务 bug,只能解决"类型不匹配"这一类 bug。其他类型的 bug(逻辑错、边界条件错、并发错)仍然要靠测试、code review、运行时校验来防护。把 TS 当成"银弹"是迁移失败的常见原因。
第五条建议是让 IDE 和 CI 一起当裁判。本地 VS Code 给你即时反馈,CI 兜底防止漏网,两者必须配置一致(同一份 tsconfig、同一个 TS 版本、同一套 ESLint 规则),否则会出现"本地能编 CI 报错"或者反过来的尴尬。我们在仓库根目录放了 .vscode/settings.json 把 TS 版本和路径都写死,确保所有人 IDE 行为一致,这条小事节省了无数次扯皮时间。
Project References:大型仓库的编译加速利器
项目过了 4 万行之后我们发现 tsc --noEmit 一次全量类型检查要跑 28 秒,本地开发体验直线下降。后来引入了 TypeScript 的 Project References 特性,把单一大项目拆成 4 个子项目(core / api / workers / shared),互相通过 references 引用,tsc 可以增量编译只重做改动的子项目,全量检查时间压到了 9 秒,改单文件的增量检查只要 1.2 秒。这是大项目几乎必须用的特性,但因为配置复杂,很多团队即使知道也没用上。
// 根 tsconfig.json: 不直接编译, 只引用子项目
{
"files": [],
"references": [
{ "path": "./packages/shared" },
{ "path": "./packages/core" },
{ "path": "./packages/api" },
{ "path": "./packages/workers" }
]
}
// packages/shared/tsconfig.json: 基础类型库
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true, // ← 启用 project reference 必须开
"declaration": true, // ← 输出 .d.ts 给上游用
"declarationMap": true, // ← 输出 .d.ts.map, 跳转源码用
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
// packages/api/tsconfig.json: 依赖 shared
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src"
},
"references": [
{ "path": "../shared" } // ← 明确依赖, tsc 会先编 shared
],
"include": ["src/**/*"]
}
启用 Project References 之后命令也要变:tsc --build(或者 tsc -b),不是裸 tsc。tsc -b --watch 会智能 watch 所有子项目,任何文件变动只重编必要部分。CI 也建议改成 tsc -b --force(强制全量,避免缓存陈旧)。这套机制和 Bazel / Nx 的增量编译思路一致,本质是把"依赖图 + 缓存"做进了 TS 自己,小项目用不上,但行数过万就值得引入。
顺手享受 TS 5.x 的几个新特性
迁移收口之后我们升到 TypeScript 5.3,有几个新特性在实际业务里收益挺明显,值得专门提一下。第一是 const 类型参数(TS 5.0 引入),让泛型可以保留字面量类型而不是被宽化成 string;第二是 satisfies 操作符(TS 4.9 引入,5.x 完善),既能做类型校验又能保留具体推导;第三是装饰器从 experimental 转正(TS 5.0),终于可以放心用了;第四是 import attributes(TS 5.3),让 JSON / CSS 等非 JS 资源的导入更规范。
// 1. const 类型参数: 泛型保留字面量
function pick<const T extends readonly string[]>(arr: T): T[number] {
return arr[0];
}
const r = pick(['a', 'b', 'c']);
// 没有 const: r 的类型是 string
// 加了 const: r 的类型是 'a' | 'b' | 'c'
// 2. satisfies: 既要类型约束, 又要保留字面量推导
type Config = Record<string, string | string[]>;
// 反模式: 用 as Config, 失去具体推导
const config1 = {
host: 'localhost',
ports: ['8080', '8081']
} as Config;
config1.ports.map(p => p); // p 类型变成了 string | string[], 错误
// 正确: satisfies
const config2 = {
host: 'localhost',
ports: ['8080', '8081']
} satisfies Config;
config2.ports.map(p => p); // p 是 string, 类型正确
// 3. 装饰器 (转正后, 不再需要 experimentalDecorators)
function logged(target: any, ctx: ClassMethodDecoratorContext) {
return function (this: any, ...args: any[]) {
console.log(`[${String(ctx.name)}] called with`, args);
return target.apply(this, args);
};
}
class OrderService {
@logged
createOrder(userId: string, amount: number) {
return { id: 'o_' + Date.now(), userId, amount };
}
}
// 4. import attributes (TS 5.3+, 替代 import assertions)
import data from './data.json' with { type: 'json' };
这几个特性里 satisfies 是我们用得最多的,几乎所有"配置常量"的定义都改成了 satisfies SomeType 模式,既有类型保证又保留了具体的字面量信息,在做精细类型推导(比如根据配置 key 推导 value 类型)时非常香。const 类型参数也很有用,主要在写工具函数(比如自定义的 select / pick / pluck)时让返回类型更精确。
团队 onboarding 文档:新人的第一周
迁移完成后我们重写了 onboarding 文档,把"读完才能动代码"的清单减到了 12 项,新人按这个清单走,一周内基本能独立交付小需求。这套文档的核心思路是把规矩变成可学习的清单,而不是散落在各种 PR 评论里。这种"工程文化的显式化"是任何成熟团队都应该做的事,迁移本身是个好契机。
三个月之后的回访
迁移收口三个月后我们做了一次回访,几个真实体感值得分享。第一,开发幸福感显著提升:IDE 自动补全、refactor 工具、go to definition 这些以前只能在 IDEA 里享受的能力,现在在 VS Code + TS 也都有了,团队同学普遍反映"再也不想写 JS 了"。第二,新人上手时间从 3-4 周缩短到 1-2 周:类型签名本身就是文档,新人不需要再去翻散落在各处的注释和实际调用方式去猜函数参数。
第三,跨团队协作变顺畅:我们和前端 React 团队共享了一批类型定义(放在 shared-types 包里),后端定义的 API 入参类型,前端可以直接引用,杜绝了"后端改了 schema 没告诉前端"的事故。这种类型作为团队间契约的用法,是 TypeScript 最大的工程价值之一。
总结
这次 90 天的 JS→TS 迁移给我们最大的收获不是"代码更安全了",而是团队对类型系统的认知发生了根本变化。之前大家觉得 TypeScript 是"加点类型注解的 JS",迁完才意识到 TS 是一个独立的、强大的、有完整工程化体系的语言,正确使用它需要重新学习架构组织、模块划分、契约设计。这种认知升级比代码本身的改变更有价值。
给所有正在犹豫"要不要迁 TS"的团队一个建议:迁,越早越好。每多积累一个月的 JS 代码,迁移成本就增加一份。如果项目还在快速迭代,现在是最好的时机;如果项目已经稳定,那就把它列入下半年的 OKR,集中两到三个月把这事做完。这次投入带来的回报会在未来三五年里持续兑现,是少数"绝对值得做"的技术升级之一。如果你正在准备启动这件事,这篇文章里所有踩过的坑都可以直接拿去复用,愿你少走一些我们走过的弯路,把节省下来的时间留给真正的业务创新。
—— 别看了 · 2026