某个函数的 timeout 参数要求毫秒,你传了 5000。另一个函数要求秒,你传了 5。中间某个地方,你调用了 setTimeout(duration, callback),结果整整一小时二十三分钟什么也没发生。

TypeScript 在这里帮不了你。50005 都是 number。编译器分辨不出米和英尺、摄氏和华氏、时间戳和时长的区别。你的测试套件大概率也抓不到,因为计算本身是对的,错的是单位。

解决办法是:不要把单位当成文档,要把它当成类型。

为什么 number 不是物理量的正确类型

TypeScript 采用结构类型。只要形状匹配,两个对象就兼容。这通常是优点,但对 number 这样的原始类型来说,意味着所有数字都可以互换。number 就是 number,仅此而已。

运行时检查确实能发现单位错误,但维护成本高,还容易被跳过。你得验证每个函数参数、每个 API 响应、每个定义在别处的常量。实际上没人这么做。检查最终变成注释,而注释会撒谎。

替代方案是把单位直接编码进类型。在编译期,SecondsMilliseconds 就是不兼容的类型。MetersMeters 得到 SquareMetersMilesKilometers,编译器直接拒绝。到了运行时,值仍然只是一个数字,没有包装对象、没有运行时校验、没有性能损耗。这就是零成本抽象。

幻型(phantom types)如何把数字变成 branded unit

TypeScript 不支持原始类型的标称类型(nominal typing),但它支持交叉类型和 unique symbol。你可以给原始类型打上 brand,让两个 brand 即使底层值相同也不兼容。

模式如下:

type Brand<T, B> = T & { readonly __brand: B };

type Meters = Brand<number, "Meters">;
type Kilometers = Brand<number, "Kilometers">;
type Seconds = Brand<number, "Seconds">;
type Milliseconds = Brand<number, "Milliseconds">;

__brand property 在运行时并不存在。它是一个 phantom type,只存在于类型系统中。但这已经足够让 MetersKilometers 互不兼容。

你不能意外地把普通 number 赋给 branded type。这正是关键所在。你必须显式构造,从而被迫说明单位。

一个在 TypeScript 中可用的单位系统

下面是一个最小但完整的实现,涵盖构造、转换和运算。

type Brand<T, B> = T & { readonly __brand: B };

type Meters = Brand<number, "Meters">;
type Kilometers = Brand<number, "Kilometers">;
type Seconds = Brand<number, "Seconds">;
type Milliseconds = Brand<number, "Milliseconds">;
type MetersPerSecond = Brand<number, "MetersPerSecond">;

function meters(value: number): Meters {
  return value as Meters;
}

function kilometers(value: number): Kilometers {
  return value as Kilometers;
}

function seconds(value: number): Seconds {
  return value as Seconds;
}

function milliseconds(value: number): Milliseconds {
  return value as Milliseconds;
}

function toMeters(km: Kilometers): Meters {
  return meters(km * 1000);
}

function toSeconds(ms: Milliseconds): Seconds {
  return seconds(ms / 1000);
}

function toMilliseconds(s: Seconds): Milliseconds {
  return milliseconds(s * 1000);
}

function addMeters(a: Meters, b: Meters): Meters {
  return meters(a + b);
}

function speed(distance: Meters, time: Seconds): MetersPerSecond {
  return (distance / time) as MetersPerSecond;
}

用法:

const d1 = kilometers(5);
const d2 = meters(200);
const t = seconds(10);

// This compiles.
const totalDistance = addMeters(toMeters(d1), d2);
const velocity = speed(totalDistance, t);

// This does not.
const bad = addMeters(d1, d2);
//     ^^^ Argument of type 'Kilometers' is not assignable to parameter of type 'Meters'.

const alsoBad = speed(totalDistance, milliseconds(5000));
//             ^^^^^ Argument of type 'Milliseconds' is not assignable to parameter of type 'Seconds'.

错误出现在 bug 被引入的地方,而不是值最终被使用的地方。你不需要跨三个文件追溯 velocity,才发现有人把毫秒传给了要求秒的参数。

从基本单位推导复合单位

这种模式可以扩展到复合单位。不必手写 MetersPerSecond,你可以用泛型构造器从基本类型推导出来。

type Per<A, B> = Brand<number, { numerator: A; denominator: B }>;
type Times<A, B> = Brand<number, { left: A; right: B }>;

type MetersPerSecond = Per<Meters, Seconds>;
type SquareMeters = Times<Meters, Meters>;

function per<A, B>(numerator: Brand<number, A>, denominator: Brand<number, B>): Per<A, B> {
  return (numerator / denominator) as Per<A, B>;
}

function times<A, B>(left: Brand<number, A>, right: Brand<number, B>): Times<A, B> {
  return (left * right) as Times<A, B>;
}

实际上,你可能不需要完整的量纲分析。大多数团队在定义了十几个单位类型后就会遇到边际收益递减。目标不是模拟物理,而是消除最昂贵的一类 bug:计算对了,但单位错了。

你需要了解的权衡

Branded types 并非没有代价,它们牺牲的是人体工学(ergonomics)。

每个字面量都必须包一层构造器。setTimeout(callback, 5000) 变成 setTimeout(callback, milliseconds(5000))。这增加了打字量。如果团队使用构造器不一致,代码库里就会到处散落不安全的类型断言。这个模式只有在人人都遵守时才有效。

类型推断也会变吵。数组方法和泛型函数可能在报错信息中暴露 brand。普通的 number[](number & { readonly __brand: "Milliseconds" })[] 好读得多。你可能需要类型别名来保持签名可读。

序列化是另一个摩擦点。JSON 没有 branded types 的概念。当你把一个 Meters 值发送到网络另一端,它到达时只是一个普通 number。你必须在边界处重建 brand。这样做是对的,但要多写代码。

最大的限制是这只是一个 TypeScript 专属技巧。如果你的系统包含 Python 服务、Go 微服务或纯 JavaScript 消费者,brand 会在语言边界消失。你仍然需要在系统边缘做运行时校验。Branded types 保护的是内部 TypeScript 代码,不能替代外部数据的 schema。

如何在不让团队反感的情况下引入

不要给代码库里每个数字都打上 brand。从那些真正引发过事故的参数开始。

  1. 找出生产环境最近三个与单位相关的 bug。关注毫秒 vs 秒、不同面额的货币、纬度 vs 经度、屏幕坐标 vs 文档坐标。
  2. 给这些特定类型打上 brand,添加构造器和转换函数。
  3. 更新使用这些值的函数,让编译器引导你。
  4. 添加 lint 规则,禁止在这些参数处使用裸 number

不要给循环计数器、数组索引或百分比打 brand。这些是无量纲的。在那里加 brand 只是无意义的仪式。

如果你使用的语言支持更强的标称类型,你有更好的选择。Rust 用户可以看看 uom crate。F# 和 OCaml 把度量单位内建进了编译器。TypeScript 的结构类型系统让这只能成为一种变通方案,而非一等特性。但这个变通方案足以抓住真正的 bug。

常见问题

这会带来运行时开销吗?

不会。brand 只是编译期构造。编译后,meters(100) 就是数字 100。没有包装对象、没有额外属性、没有运行时检查。

乘除怎么办?

你需要显式函数或重载运算符。TypeScript 不支持运算符重载,所以 distance / time 必须经过 per()speed() 这样的函数。这很冗长,但正因如此 bug 才会被抓到。

能和第三方库一起用吗?

只有当库接受你的 branded type 时才可以。如果 setTimeout 期望 number,你可以传 Milliseconds,因为 brand 是与 number 的交叉类型。反过来不行。如果库返回 number,你必须显式地给它打上 brand 才能当作单位类型使用。

如何处理像 HalfSeconds 这样的分数单位?

使用基本单位和构造器。halfSeconds(1) 返回 Milliseconds(500)。不要为每个细分单位都创建 brand。保持基本单位的数量尽量少。

别再把单位名写在变量名里

把变量叫 timeoutInMs 那是文档。文档会漂移。把它叫 timeout: Milliseconds 那是类型。类型是强制的。

下次你调试问题时发现 timeout 差了一千倍,问问自己变量名够不够用。不够。把单位编码进类型,让编译器干活,别再相信你自己能记住这个函数到底要秒还是要毫秒。