把老 JavaScript 项目迁到 TypeScript,最大的风险不是技术问题,而是一次性开 strict 导致上千个错误、项目卡死。正确做法是分阶段推进。
分阶段策略
1// 从 JS 迁移的分阶段配置(这里用注释演示,实际写在 tsconfig.json 里)
2//
3// 阶段 0:先让项目跑起来
4// {
5// "compilerOptions": {
6// "allowJs": true,
7// "checkJs": false,
8// "noEmit": true,
9// "strict": false
10// }
11// }
12//
13// 阶段 1:逐文件改后缀(.js → .ts),一次一个
14//
15// 阶段 2:收紧配置(按模块逐步开,不要全局一次开)
16// {
17// "compilerOptions": {
18// "strict": true,
19// "noImplicitAny": true,
20// "strictNullChecks": true
21// }
22// }
23//
24// 阶段 3:治理 any
25
26// ---- 这个文件演示迁移中的常见模式 ----
27
28// ---- 模式 1:any → unknown,强制调用方收窄 ----
29// 改前:function parse(json: string): any { return JSON.parse(json) }
30function parse(json: string): unknown {
31 return JSON.parse(json);
32}
33
34// 调用方必须收窄
35function parseUser(json: string): { name: string } {
36 const raw = parse(json);
37 if (typeof raw === "object" && raw !== null && "name" in raw) {
38 const name = (raw as { name: unknown }).name;
39 if (typeof name === "string") return { name };
40 }
41 throw new Error("invalid user");
42}
43
44// ---- 模式 2:边界处做运行时校验 ----
45function readEnv(key: string): string | undefined {
46 const v = process.env[key];
47 return typeof v === "string" ? v : undefined;
48}
49
50// ---- 模式 3:用类型谓词收窄外部数据 ----
51interface ApiResponse { id: number; name: string }
52
53function isApiResponse(v: unknown): v is ApiResponse {
54 return (
55 typeof v === "object" && v !== null &&
56 typeof (v as ApiResponse).id === "number" &&
57 typeof (v as ApiResponse).name === "string"
58 );
59}
60
61// ---- 模式 4:逐步替换 any 为具体类型 ----
62// 改前:function process(data: any) { return data.items.map((x: any) => x.id) }
63interface Data { items: { id: number }[] }
64// 注意:不要用 process 当函数名,它会遮蔽 Node 的全局 process
65function extractIds(data: Data): number[] {
66 return data.items.map((x) => x.id);
67}
68
69// ---- 模式 5:CommonJS → ESM ----
70// 改前(老 JS 代码)
71// const fs = require("fs");
72// module.exports = { run };
73// 改后
74import fs from "node:fs";
75export function run(): string {
76 return fs.existsSync(".") ? "ok" : "no";
77}
78
79// ---- 模式 6:类字段初始化的语义差异 ----
80// useDefineForClassFields 默认为 true(target es2022+)时,
81// 子类字段会覆盖父类访问器(报 TS2610)
82class Base {
83 set value(v: number) { void v; }
84}
85class Derived extends Base {
86 // 如果依赖老行为(走 setter),需要声明成访问器或设 useDefineForClassFields: false
87 private _value = 0;
88 override set value(v: number) { this._value = v; }
89 get value(): number { return this._value; }
90}
91
92const d = new Derived();
93d.value = 1;
94
95console.log(parseUser('{"name":"a"}'), readEnv("PATH") !== undefined, isApiResponse({ id: 1, name: "a" }));
96console.log(extractIds({ items: [{ id: 1 }, { id: 2 }] }), run(), d.value);
1{ name: 'a' } true true
2[ 1, 2 ] ok 1
阶段 0:先让项目跑起来
1{
2 "compilerOptions": {
3 "allowJs": true,
4 "checkJs": false,
5 "noEmit": true,
6 "strict": false
7 }
8}
allowJs:让.js也参与编译(但用checkJs: false不检查类型)noEmit:只检查,不产出strict: false:先别开严格模式
这一步的目标只是让 tsc --noEmit 能跑通,不改任何代码。
阶段 1:逐文件改后缀
把 .js 改成 .ts,一次一个文件,改完就修这个文件的类型错误。不要批量改。
阶段 2:收紧配置
1{
2 "compilerOptions": {
3 "strict": true
4 }
5}
按模块逐步开。老项目全局一次开 strict 会瞬间出现大量错误。可以先用目录级的 tsconfig.json 分批推进。
阶段 3:治理 any
用 noImplicitAny 找出隐式 any,用 lint 限制显式 any:
1{ "rules": { "@typescript-eslint/no-explicit-any": "warn" } }
any 治理的实用顺序
第一步:any → unknown
这是收益最大的一步。unknown 强制调用方收窄(见 类型收窄),能立刻暴露大量隐藏的假设:
1// 改前
2function parse(json: string): any { return JSON.parse(json) }
3
4// 改后
5function parse(json: string): unknown { return JSON.parse(json) }
改完后调用方全部报错——这正是价值所在,每个报错点都是一个需要处理的假设。
第二步:边界处做运行时校验
JSON.parse、process.env、HTTP 响应这些地方,类型系统无法保证正确性:
1function readEnv(key: string): string | undefined {
2 const v = process.env[key];
3 return typeof v === "string" ? v : undefined;
4}
用类型谓词封装校验逻辑:
1function isApiResponse(v: unknown): v is ApiResponse {
2 return (
3 typeof v === "object" && v !== null &&
4 typeof (v as ApiResponse).id === "number" &&
5 typeof (v as ApiResponse).name === "string"
6 );
7}
手写谓词在字段多时很啰嗦。zod / valibot 这类库能在运行时校验的同时推导出类型,是更彻底的方案。代价是多一个依赖。
第三步:内部代码补类型
真正的业务逻辑,类型可以从数据结构推导出来,不需要运行时校验。
常见坑
坑一:strictNullChecks 打开后错误爆炸
老代码里 obj.foo.bar 假设 foo 一定存在。打开后全部报错。
对策:先只在新文件上开,或按目录分批。TypeScript 没有官方的"文件级 strict 开关",但可以:
- 用目录级
tsconfig.json分片 - 或者接受一段时间的错误,用
// @ts-expect-error临时标记(记得记账)
坑二:this 类型推断差异
1// JS 里没问题
2const obj = {
3 count: 0,
4 inc() { this.count++; }
5};
noImplicitThis 会要求注解 this。对象方法简写通常能自动推断,但把函数拆出去就会报错:
1const inc = obj.inc;
2inc(); // this 丢失
对策:用箭头函数绑定,或显式声明 this 参数(见 函数类型)。
坑三:CommonJS 与 ESM 混用
1// 老代码
2const fs = require("fs");
3module.exports = { run };
4
5// TS
6import fs from "node:fs";
7export function run(): string { }
esModuleInterop: true + module: "nodenext" 能处理大部分情况,但 require/import 混用的文件需要逐个改。
坑四:类字段初始化语义变化
useDefineForClassFields 默认为 true(target 为 es2022+)时,子类字段会覆盖父类访问器:
1class Base {
2 set value(v: number) { void v; }
3}
4class Derived extends Base {
5 value = 1;
6}
7// error TS2610: 'value' is defined as an accessor in class 'Base', but is
8// overridden here in 'Derived' as an instance property.
对策:改用访问器,或设 useDefineForClassFields: false(如果依赖老行为)。
坑五:不要用 process 当函数名
这是个实际踩到的坑:
1// ✗ 遮蔽了 Node 的全局 process
2function process(data: Data) { }
3
4// 之后所有 process.env 都会报错:
5// error TS2339: Property 'env' does not exist on type '(data: Data) => number[]'.
对策:换个名字(extractIds、handleData)。
迁移检查清单
| 检查项 | 说明 |
|---|---|
allowJs + checkJs: false | 阶段 0 的基础 |
| 逐文件改后缀 | 不要批量 |
目录级 tsconfig.json | 分批收紧严格度 |
any → unknown | 收益最大的一步 |
| 边界处运行时校验 | JSON.parse、env、HTTP |
| 类型谓词封装校验 | 或用 zod |
esModuleInterop | CJS/ESM 混用 |
useDefineForClassFields | 类字段语义 |
| 检查全局变量遮蔽 | 如 process、name、length |
相关
- 工程化 —— tsconfig 完整配置
- 类型收窄 ——
unknown的处理 - .d.ts 与第三方类型 —— 给无类型库补声明
- 运行 TypeScript —— 迁移后的执行方式