Writing
从 any 到类型边界:一次渐进式 TypeScript 迁移
迁移不是给变量补类型注解,而是先用领域模型统一「口径」。这次迁移让类型相关缺陷下降约 60%。
为什么迁移总是失败
大多数 TypeScript 迁移失败在同一处:把 .js 改成 .ts,给参数补上 : any,编译通过,收工。表面上项目有了类型,实际上只是把运行时错误延后到了下一个迭代。
我在支付云管理台主导过一次完整迁移。旧系统由三段时期的技术栈拼接而成,同一个「交易状态」在不同模块有三种取值。迁移前我们做了两件事:先统一领域模型,再动代码。
第一步:找出真正的边界
类型不是越细越好,关键是找到系统的边界。对管理台来说,边界只有四个:
- 接口出入参 —— 后端契约,来自 Swagger。
- 金额与时间 —— 最容易出错、最容易产生争议的两类值。
- 状态枚举 —— 决定 UI 分支与操作权限。
- 权限与租户 —— 决定「能看什么」,不能靠组件内部判断。
据此先写一层 types/domain.ts,所有模块只从这层引入类型,不允许各自重新定义:
export type TransactionStatus = 'pending' | 'success' | 'failed' | 'reversed';
/** 金额统一以「分」为单位,展示层格式化 */
export type Cents = number & { readonly __brand: 'Cents' };
export interface Transaction {
id: string;
merchantId: string;
amount: Cents;
status: TransactionStatus;
paidAt: string | null; // ISO 8601
}
Cents 用了 brand 类型。它带来的收益非常直接:任何把「元」直接赋给 amount 的地方都会编译报错,而不需要靠评审时盯。
第二步:让编译期检查成为门禁
只在本地跑 tsc 没有意义。我们把它放进 CI,且开启了几项「疼但值得」的配置:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": false
}
}
noUncheckedIndexedAccess 是最容易被关掉的一项,因为它会让大量 arr[0].name 报错。但正是这些位置,才是线上 undefined is not an object 的真正来源。
exactOptionalPropertyTypes 我们选择关闭——它与大量第三方库的类型定义冲突,收益低于维护成本。类型严格度也要算投入产出比。
第三步:先迁移数据层,再迁移视图
迁移顺序决定了风险曲线。我们按「数据层 → 业务组件 → 页面」推进:
| 阶段 | 范围 | 验收标准 |
|---|---|---|
| 1 | API client 与领域模型 | 所有响应有明确类型,禁止 any 出口 |
| 2 | 通用表格 / 图表 / 表单 | 泛型化,调用方无需断言 |
| 3 | 业务组件 | 无 as 断言,props 必填项明确 |
| 4 | 页面 | 删除临时 @ts-ignore,开启全量检查 |
每一阶段都对应一次可发布的改动,任何一步出问题都能独立回滚。相比「一次性大迁移 + 长期分支」,这种方式的心理负担小得多。
遇到的问题
问题一:泛型组件写成了类型体操。 早期表格组件定义了七个泛型参数,结果调用方为了通过检查要写三行类型。后来砍到两个(行类型、主键类型),其余用推导,可用性立刻回升。
问题二:把类型当文档写。 有些同事给每个函数都写了极长的类型,但实际的运行时校验依然缺失。类型只覆盖编译期,来自接口的数据仍然必须在边界处校验(我们用 @midwayjs/validate 在服务端做,前端只做必要防御)。
结果
- 核心模块 TypeScript 覆盖率达到 100%,类型相关缺陷下降约 60%。
- 金额与时区问题基本消失,对账页面的争议从「每周几次」变为「几乎没有」。
- 新同学接手模块时,读类型定义就能理解数据结构,减少了对口头交接的依赖。
一点建议
如果你的项目正在犹豫要不要迁 TypeScript,先问一个问题:团队对核心概念的口径统一吗? 如果金额单位、时间格式、状态取值还在各自表述,那么迁移只会把这些混乱固化成类型。反过来,如果口径已经清晰,TypeScript 会成为一个非常便宜的守门人。