把一个现有的 JavaScript 项目迁移到 TypeScript 不需要一次全部重写。TypeScript 编译器支持渐进式迁移,允许 .js 和 .ts 文件在同一源码目录中和平共处,开发团队可以按模块逐个完成类型化改造。这种策略对于大型遗留项目尤其重要,因为一次性重写不仅风险高,而且会阻塞正常的业务迭代。本文将提供一条经过大量生产环境验证的完整迁移路径,涵盖配置、工具、运行时边界、构建链调整以及团队推广策略,帮助你在不影响交付节奏的前提下,把代码库的类型安全水平提升到新的高度。
一、迁移策略
策略一:松耦合 → 严约束(推荐)
这是大多数团队首选的路径,核心思想是先让 TypeScript 编译器接纳现有 JavaScript 代码,再逐步收紧类型约束。具体可以分为四个阶段实施:
第一阶段:Rename。在不对任何代码逻辑做修改的前提下,将核心模块的文件扩展名从 .js 改为 .ts。此时由于 allowJs 选项开启,项目中 .js 文件仍然可以正常编译,团队可以感受到零回退成本。
第二阶段:JSDoc 标注。对于暂时不想重命名的 .js 文件,可以通过在函数上方添加 JSDoc 注释来提前获得类型提示。TypeScript 编辑器支持读取 JSDoc 中的 @param 和 @returns 标签,这相当于在运行时零开销的情况下引入类型约束。
第三阶段:allowJs 共存。在 tsconfig.json 中开启 allowJs: true,让 .ts 文件可以安全地 import .js 模块。此时新项目或重构模块用 TypeScript 编写,旧模块保持 JavaScript,两者并行不悖。
第四阶段:strict 全开。当代码库中 TypeScript 文件占比达到预期后,逐步开启 strict 子选项,最终收敛到完整的严格模式,彻底消除隐式 any。
每次提交的颗粒度建议控制在一个文件或一个目录,Pull Request 的 diff 不应超过五百行。这样代码审查者能够集中精力在类型变化上,而不会被业务逻辑的重写分散注意力。团队推广时,建议先在一两个非核心业务模块做试点,积累 tsconfig 配置经验后,再写一份内部迁移指南,由资深开发者做代码审查,确保类型注解的风格统一。
策略二:边界优先
如果你的项目是一个对外暴露公共接口的库或者服务端框架,可以从边界向中心推进:
- 先给公共 API 和接口编写
.d.ts声明文件,外部消费者最先受益; - 所有新模块强制使用
.ts编写,从源头拦截类型问题; - 旧模块在修复 Bug 或增加功能时顺便迁移,利用代码变更的窗口期完成类型化;
- 核心业务逻辑放在最后迁移,因为它往往涉及复杂的类型推导和大量的 any 清理。
二、第一步:安装与配置
安装 TypeScript 官方包以及 Node.js 内置 API 的类型声明:
npm install -D typescript @types/node
npx tsc --init
生成的 tsconfig.json 初始配置通常比较保守,建议按以下方式调整:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"allowJs": true,
"checkJs": false,
"outDir": "./dist",
"rootDir": "./src",
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
allowJs: true 是迁移的核心开关,它允许 TypeScript 编译器把 .js 文件当作源码处理并输出到 outDir。checkJs: false 则告诉编译器先不要对 .js 文件进行类型检查,避免一次性爆出几千个错误让开发者望而却步。rootDir 与 outDir 配合确保编译后的目录结构与源码保持一致,这样相对路径的 import 在编译前后都不会被破坏。
三、自动转换工具
ts-migrate(Airbnb 出品)
ts-migrate 是大规模迁移的利器,它将批量重命名、错误标注和占位类型整合在一个命令中:
# 安装
npm install -D ts-migrate
# 在指定目录执行完整迁移流程
npx ts-migrate-full folder-name
其内部执行顺序如下:首先将所有 .js 与 .jsx 文件重命名为 .ts 与 .tsx;然后运行 tsc 收集类型错误;接着在每个错误位置自动插入 @ts-expect-error 注释,并给缺少类型的变量显式标注 any。这样生成的代码是可以通过编译的,团队后续只需要按优先级逐步替换这些 any 和 @ts-expect-error。
TypeStat
如果你的团队希望在迁移的同时尽可能多地自动推断出真实类型,可以用 TypeStat:
{
"include": ["src/**/*.{ts,tsx}"],
"fixMissingProperties": true,
"fixNoImplicitAny": true,
"types": {
"implicitClassGenerics": true,
"implicitFunctionGenerics": true
}
}
npx typestat --config typestat.json
TypeStat 会基于现有代码中的调用模式推断参数与返回值类型,比单纯的 any 占位要精确得多。对于函数式代码库,它可以自动补全高阶函数的泛型参数,显著减少人工标注工作量。
dts-gen(微软官方)
对于没有类型声明的第三方 JavaScript 库,可以先用 dts-gen 自动生成初始声明:
npm install -D dts-gen
npx dts-gen -m some-legacy-lib
生成的 .d.ts 文件虽然粗糙,但足以消除编译错误,后续可以人工精修类型细节。
四、渐进启用严格模式
一次性开启 strict: true 对大型 JavaScript 代码库来说往往意味着数千个编译错误。更务实的做法是将严格模式的各个子选项按阶段打开:
{
"compilerOptions": {
"strict": false,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"strict": true
}
}
第一步:noImplicitAny。这是最基础也最有效的选项。开启后,所有没有显式类型且无法被推断的变量都会报错,迫使开发者做出明确选择——要么补充类型,要么显式标注 any。
第二步:strictNullChecks。JavaScript 中最常见的一类运行时错误就是空指针异常。开启 strictNullChecks 后,null 和 undefined 将不再自动赋值给其他类型,访问对象属性前必须进行非空判断。
第三步:strictFunctionTypes。函数参数逆变检查能够捕获回调函数签名不匹配的隐患,对事件驱动型代码库尤为重要。
第四步:strictBindCallApply。确保 Function.prototype.call、apply 和 bind 的类型安全。
第五步:strictPropertyInitialization。类属性如果在声明时没有赋值且构造函数中也没有赋值,将产生编译错误。
最终阶段:strict: true。当所有子选项逐一攻克后,将顶层 strict 设为 true,从配置层面锁定团队不再回退。
五、第三方库类型处理
现代 JavaScript 生态中,大部分流行库都在 DefinitelyTyped 上有社区维护的类型声明:
# 安装社区类型声明
npm install -D @types/lodash @types/express @types/react
如果某個库没有官方类型包,又不值得自己从零编写完整声明,可以在项目中创建一个兜底模块声明:
// src/types/legacy-lib.d.ts
declare module 'legacy-lib' {
const anything: any;
export = anything;
}
对于仅有默认导出且结构简单的库,也可以直接用 declare module 为其添加类型:
// src/types/shims.d.ts
declare module 'my-utils' {
export function formatDate(date: Date, pattern: string): string;
export function parseId(input: string): number;
}
在 monorepo 中,如果多个子项目依赖同一个内部工具库,建议为该库编写一份正式的 .d.ts 声明文件,并通过 TypeScript 的 project references 或包管理器的 workspace 链接共享,避免重复声明导致的一致性问题。
六、运行时边界处理
TypeScript 的类型系统仅在编译时生效,代码运行后所有类型信息都会被擦除。这意味着来自外部的数据——例如用户输入、HTTP 响应、本地存储内容、URL 查询参数——仍然可能在运行时带有错误的结构。处理这些边界是迁移过程中最容易被忽视、但危害最大的环节。
初始化 unknown 再收窄
对于任何外部来源的数据,优先使用 unknown 而非 any:
// src/api/fetchUser.ts
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
const data: unknown = await res.json();
if (
typeof data === 'object' &&
data !== null &&
'id' in data &&
'name' in data &&
typeof (data as Record<string, unknown>).id === 'string' &&
typeof (data as Record<string, unknown>).name === 'string'
) {
return data as User;
}
throw new Error('Invalid user response structure');
}
Zod 运行时验证
在关键路径上使用 Zod 进行 Schema 校验,可以在运行时同时完成类型断言和数据验证:
// src/schemas/user.ts
import { z } from 'zod';
export const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().min(0).optional(),
});
export type User = z.infer<typeof UserSchema>;
// src/api/fetchUser.ts
import { UserSchema } from '../schemas/user';
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
const json = await res.json();
return UserSchema.parse(json); // 运行时校验 + 类型推断
}
本地存储的安全读取
localStorage 是常见的运行时边界,读取后务必做解析和验证:
// src/utils/storage.ts
export function getStorageItem<T>(key: string, validate: (v: unknown) => T | null): T | null {
try {
const raw = localStorage.getItem(key);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
return validate(parsed);
} catch {
return null;
}
}
七、构建工具链迁移
Babel 与 tsc 双轨并行
很多项目原本使用 Babel 做转译以支持新语法特性。迁移时不必立刻放弃 Babel,可以让 tsc 只负责类型检查(--noEmit),而 Babel 继续负责代码转译与打包:
{
"scripts": {
"build": "babel src --out-dir dist",
"type-check": "tsc --noEmit"
}
}
这种双轨策略的优势在于,开发团队可以在不改变现有构建流程的情况下引入 TypeScript 类型检查,降低迁移阻力。
esbuild 与 swc 快速编译
对于中大型项目,tsc 的编译速度可能成为性能瓶颈。esbuild 和 swc 都是 Rust 编写的高性能编译器,能够在毫秒级完成大型代码库的转译:
// esbuild.config.ts
import * as esbuild from 'esbuild';
await esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
outdir: 'dist',
platform: 'node',
format: 'cjs',
});
需要注意的是,esbuild 和 swc 只做转译不做类型检查,因此仍需在 CI 中保留 tsc --noEmit 作为独立步骤。
Monorepo 中的 project references
在 monorepo 中跨包引用时,应使用 TypeScript 的 project references 机制来确保增量编译和类型一致性:
// packages/core/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
// packages/app/tsconfig.json
{
"references": [
{ "path": "../core" }
],
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
在根目录创建一个聚合配置:
// tsconfig.json
{
"files": [],
"references": [
{ "path": "./packages/core" },
{ "path": "./packages/app" }
]
}
这样执行 tsc --build 时,TypeScript 会自动按依赖关系拓扑排序并做增量编译,修改一个子包时无需重新编译整个仓库。
八、常见迁移陷阱
循环依赖的类型推导死锁
当两个 .ts 模块通过 import 互相依赖时,TypeScript 的类型推导可能出现死锁或生成隐式 any。建议在迁移初期通过代码拆分或引入第三个公共模块来打破循环,等类型稳定后再考虑重构回合理的引用结构。
this 绑定丢失
JavaScript 中常见的 .map(callback) 或 setTimeout(callback) 场景下,this 的指向在迁移后可能因严格模式而产生编译错误:
class Timer {
private count = 0;
start() {
// 错误:this 在回调中丢失上下文
// setTimeout(function () { this.count++; }, 1000);
// 正确:使用箭头函数
setTimeout(() => {
this.count++;
}, 1000);
}
}
原型链扩展的类型声明
如果旧代码通过 String.prototype 或 Array.prototype 添加了自定义方法,迁移时需要启用声明合并:
// src/types/extensions.d.ts
declare global {
interface String {
toSnakeCase(): string;
}
}
export {};
// src/utils/string.ts
String.prototype.toSnakeCase = function (): string {
return this.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
};
CommonJS require 的类型处理
遗留代码中大量使用 require() 时,可以通过 esModuleInterop 和 allowSyntheticDefaultImports 来兼容,但推荐逐步替换为 ES
Module 的 import:
// 旧代码
// const fs = require('fs');
// 新代码
import * as fs from 'fs';
// 或
import fs from 'fs';
装饰器迁移
如果项目使用了实验性装饰器,需要在 tsconfig.json 中显式开启并注意版本兼容性:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
九、类型推断与补充
手写 .d.ts 声明文件
当团队维护的公共模块被多个项目消费时,为其编写独立的 .d.ts 声明文件能够极大提升下游开发体验。一份良好的声明文件应当包含接口定义、泛型参数以及详细的 JSDoc 注释:
// types/http-client.d.ts
export interface RequestConfig<TParams = unknown, TBody = unknown> {
url: string;
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
params?: TParams;
body?: TBody;
headers?: Record<string, string>;
}
export interface ApiResponse<TData> {
code: number;
data: TData;
message: string;
}
export declare class HttpClient {
get<T>(config: RequestConfig<unknown, unknown>): Promise<ApiResponse<T>>;
post<T, B>(config: RequestConfig<unknown, B>): Promise<ApiResponse<T>>;
}
any 与 unknown 的策略选择
迁移初期不可避免地会出现 any,但需要建立清晰的清理策略。建议约定:
- 临时任何使用
any,并在同一行追加// TODO: replace any with real type; - 对运行时数据一律使用
unknown,迫使调用方进行类型收窄; - 不允许在公共 API 的返回值或参数中出现隐式
any。
// src/utils/legacy.ts
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function parseLegacyFormat(input: string): any {
return JSON.parse(input);
}
利用类型断言进行局部迁移
当某个复杂对象的结构尚未完整类型化时,可以使用类型断言(as)来维持编译通过,同时逐步替换:
interface Config {
host: string;
port: number;
}
const raw = JSON.parse(readFileSync('./config.json', 'utf-8'));
const config = raw as Config; // 先断言,后续用 Zod 替换
十、大型项目案例
百万行代码库迁移经验
Airbnb 在 2019 年将超过两百万行 JavaScript 代码迁移至 TypeScript,其核心经验包括:
- 自动化优先:使用
ts-migrate一次性完成重命名与任何占位,避免开发者陷入手工修改的泥潭; - 模块粒度:按业务领域拆分迁移批次,每次最多迁移一个子目录,确保代码审查可控;
- 类型检查门禁:在 CI 流水线中加入
tsc --noEmit,任何新提交的代码都必须通过类型检查; - 度量驱动:统计每周消除的
any数量和新增的严格模式覆盖率,用数据驱动迁移进度。
CI 中的 type-check gate
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
type-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npx tsc --noEmit
- run: npm run lint
- run: npm test
将 tsc --noEmit 放在测试任务之前非常重要,因为类型错误是成本最低的缺陷——在编码阶段就能发现,无需运行测试套件。
迁移前后缺陷率对比
根据多个公开技术分享的数据显示,在大型 JavaScript 项目迁移到 TypeScript 并开启 strict 模式后,生产环境中由类型错误引发的异常(如 undefined is not a function、Cannot read properties of null 等)平均下降百分之三十到百分之五十。对于外卖调度、金融交易等高风险领域,这种提升尤为显著,因为它把大量运行时缺陷前移到编译期拦截。
迁移检查清单
- 安装 TypeScript 和配置
tsconfig.json,确认allowJs: true - 配置
rootDir与outDir,保持源码与输出目录结构一致 - 使用
ts-migrate或手工方式把核心模块重命名为.ts - 处理第三方库类型声明,安装
@types/*包或编写.d.ts兜底 - 开启
noImplicitAny,消除隐式任何 - 开启
strictNullChecks,阻断空指针风险 - 开启
strictFunctionTypes,校验回调函数签名 - 最终开启
strict: true,锁定完整严格模式 - 在关键边界引入 Zod 或 io-ts 进行运行时数据校验
- 调整构建链,实现
tsc类型检查与 Babel 或 esbuild 转译并行 - CI 流水线中加入
tsc --noEmit门禁,禁止类型错误的代码合入主干 - 建立内部规范,约定
any的使用规则和清理 timeline - 监控迁移后缺陷率变化,量化 TypeScript 引入的投资回报
总结
从 JavaScript 到 TypeScript 的迁移不是一次性的重写任务,而是一场持续数周甚至数月的渐进式工程。成功的关键在于合理分阶段、善用自动化工具、守住运行时边界,以及在 CI 中建立类型检查门禁。只要遵循松耦合到严约束的路径,配合团队的代码审查与度量机制,任何规模的 JavaScript 代码库都可以在不中断业务交付的前提下,稳步获得编译期类型安全的保护。
相关阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。