全部文章

Writing

从 any 到类型边界:一次渐进式 TypeScript 迁移

迁移不是给变量补类型注解,而是先用领域模型统一「口径」。这次迁移让类型相关缺陷下降约 60%。

为什么迁移总是失败

大多数 TypeScript 迁移失败在同一处:把 .js 改成 .ts,给参数补上 : any,编译通过,收工。表面上项目有了类型,实际上只是把运行时错误延后到了下一个迭代。

我在支付云管理台主导过一次完整迁移。旧系统由三段时期的技术栈拼接而成,同一个「交易状态」在不同模块有三种取值。迁移前我们做了两件事:先统一领域模型,再动代码。

第一步:找出真正的边界

类型不是越细越好,关键是找到系统的边界。对管理台来说,边界只有四个:

  1. 接口出入参 —— 后端契约,来自 Swagger。
  2. 金额与时间 —— 最容易出错、最容易产生争议的两类值。
  3. 状态枚举 —— 决定 UI 分支与操作权限。
  4. 权限与租户 —— 决定「能看什么」,不能靠组件内部判断。

据此先写一层 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 会成为一个非常便宜的守门人。