一场 3.27 亿美元的类型错误

2022 年,一家大型金融科技平台处理了一笔 32,700,000.00 的批量转账。金额被存储为普通的 number。下游服务以为它是以分为单位。事实并非如此。

这个 bug 逃过了代码审查、单元测试和集成测试。类型系统看到 numbernumber,就觉得没问题了。两个完全相同的类型,完美兼容,却带来了灾难性的错误。

这就是货币问题的本质。你的类型系统能阻止把字符串加到整数上,却无法阻止把日元加到美元上,也无法阻止把主单位金额当作辅单位金额处理。这些在语义上是不同的量,但在 TypeScript、Rust、Go 以及大多数主流语言中,它们共用同一个类型。

为什么原生数字类型无法胜任货币计算

numberf64intBigDecimal。它们编码的都是大小,而不是含义。

一个 number 可以表示 100 美元、100 美分或 100 欧元。类型系统把这三者视为完全相同。你可以对它们做加法、比较,也可以把它们传给任何接受 number 的函数,编译器不会有任何怨言。

function processPayment(amount: number) {
  // Is this dollars? Cents? Euros?
  // The type system does not know, so it cannot help you.
}

const usd = 100        // 100 USD
const cents = 10000    // 10000 cents, also 100 USD
const eur = 100        // 100 EUR

processPayment(usd + eur)     // Compiles. Wrong.
processPayment(usd + cents)   // Compiles. Also wrong.

标准的防御手段是命名约定。把变量叫做 amountInCentsamountUsd。这在重构、跨边界复制值、或者有人看漏名字之前是有效的。注释和命名约定无法被强制执行。

你需要的是一种既能编码数值、又能编码货币单位的类型。编译器应该像拒绝 string + number 一样拒绝 USD + EUR

Phantom Types:教会编译器认识货币

这种有效的技术叫做 phantom type。你定义一个围绕数字的泛型包装器,其类型参数携带货币标签。该类型参数在运行时永远不会出现,但编译器会用它来做约束检查。

下面是一个完整、可运行的 TypeScript 实现:

// money.ts

declare const brand: unique symbol;

type Currency = "USD" | "EUR" | "JPY" | "GBP";

type Money<C extends Currency> = number & {
  readonly [brand]: C;
};

function money<C extends Currency>(amount: number, _currency: C): Money<C> {
  return amount as Money<C>;
}

function add<C extends Currency>(a: Money<C>, b: Money<C>): Money<C> {
  return (a + b) as Money<C>;
}

function subtract<C extends Currency>(a: Money<C>, b: Money<C>): Money<C> {
  return (a - b) as Money<C>;
}

brand 符号创建了一种名义类型(nominal type)区分。两个带有不同 currency brand 的 Money 值是不兼容的,即使它们底层都是 number

用法如下:

const price = money(100, "USD");
const tax = money(8.5, "USD");
const total = add(price, tax); // Money<"USD">, OK

const invoice = money(500, "EUR");
const wrong = add(price, invoice);
// Error: Argument of type 'Money<"EUR">' is not assignable
// to parameter of type 'Money<"USD">'.

编译器现在明白 USD 和 EUR 是不同的东西。你无法意外地把它们相加,也无法把 EUR 传给期望 USD 的函数。错误会在调用现场暴露出来,而不是在生产环境的账本里。

显式处理转换

一个阻止所有混合的类型系统是无法使用的。真实系统一直在进行货币转换。诀窍是让转换变得显式、可追踪、可审计。

type ExchangeRate<From extends Currency, To extends Currency> = {
  readonly from: From;
  readonly to: To;
  readonly rate: number;
};

function convert<From extends Currency, To extends Currency>(
  amount: Money<From>,
  rate: ExchangeRate<From, To>
): Money<To> {
  return (amount * rate.rate) as Money<To>;
}

现在,货币转换成了一等公民操作,并带有类型级别的留痕记录。没有 ExchangeRate 你就无法转换,而且汇率本身也带有源货币和目标货币的类型信息。

const usdToEur: ExchangeRate<"USD", "EUR"> = {
  from: "USD",
  to: "EUR",
  rate: 0.92,
};

const euros = convert(price, usdToEur); // Money<"EUR">

如果你试图用错汇率,编译器会阻止你。如果在汇率定义里把 fromto 互换,那么所有使用该汇率的调用点都会变成类型错误。bug 会在你提交代码之前就被捕获。

主单位与辅单位的陷阱

货币并不是你唯一可以建模的维度。2022 年那家金融科技公司的 bug 并不是货币不匹配,而是单位不匹配:美元对美分。

Phantom types 也能解决这个问题。为货币单位增加第二个类型参数即可。

type Unit = "major" | "minor";

type Money<C extends Currency, U extends Unit> = number & {
  readonly [brand]: { currency: C; unit: U };
};

function money<C extends Currency, U extends Unit>(
  amount: number,
  _currency: C,
  _unit: U
): Money<C, U> {
  return amount as Money<C, U>;
}

function toMinor<C extends Currency>(
  amount: Money<C, "major">
): Money<C, "minor"> {
  return (amount * 100) as Money<C, "minor">;
}

function toMajor<C extends Currency>(
  amount: Money<C, "minor">
): Money<C, "major"> {
  return (amount / 100) as Money<C, "major">;
}

现在编译器可以同时追踪货币和单位了:

const dollars = money(100, "USD", "major");
const cents = money(10000, "USD", "minor");

const bad = add(dollars, cents);
// Error: 'Money<"USD", "minor">' not assignable to 'Money<"USD", "major">'

const fixed = add(dollars, toMajor(cents)); // Money<"USD", "major">, OK

转换函数是跨越单位边界的唯一途径。每一次跨越都是显式的、可 grep 的、可审查的。

这种模式的痛点

Phantom types 并非没有代价。它们会给日常操作增加摩擦,而且并不能解决所有与钱相关的问题。

序列化是第一个痛点。JSON 没有 branded type 的概念。当你对 Money<"USD"> 执行 JSON.stringify 时,得到的是一个普通数字。当你把它解析回来时,得到的也是一个普通数字。你必须在每个边界处进行验证并重新打上 brand。

function parseMoney<C extends Currency>(
  raw: number,
  currency: C
): Money<C> {
  if (typeof raw !== "number" || !isFinite(raw)) {
    throw new Error("Invalid money value");
  }
  return money(raw, currency);
}

第三方库是第二个痛点。大多数数学、格式化和数据库库都期望普通的 numberDecimal。你需要花时间编写适配函数或包装类型来弥合这一鸿沟。

浮点数是第三个问题。上面的示例使用了 number,这意味着 0.1 + 0.2 !== 0.3。对于金融软件来说,这是不可接受的。你应该把钱存储为以辅单位表示的整数,或者使用一个合适的 decimal 库,然后给它打上 brand,而不是给 number

import { Decimal } from "decimal.js";

type Money<C extends Currency> = Decimal & {
  readonly [brand]: C;
};

更简单的替代方案:直接用对象

如果你觉得 phantom types 有些过度设计,那么一个带有运行时验证的普通对象可以用更少的类型机制为你提供大部分安全性。

type Money = {
  readonly amount: number;
  readonly currency: Currency;
  readonly unit: Unit;
};

function add(a: Money, b: Money): Money {
  if (a.currency !== b.currency || a.unit !== b.unit) {
    throw new Error(
      `Cannot add ${a.currency} ${a.unit} to ${b.currency} ${b.unit}`
    );
  }
  return { ...a, amount: a.amount + b.amount };
}

这能在运行时而非编译时捕获错误。换取的是简单性。对于内部工具、原型项目,或者刚接触 TypeScript 的团队来说,对象方法通常是一个不错的起点。你随时可以在之后引入 phantom types。

从一个不变量开始

你不需要在第一天就建模每一种货币和每一个单位。挑出那个曾经坑过你的不变量,给它打上 brand,然后强制执行它。

如果你的团队曾经上线过美元与美分混淆的 bug,那就从单位追踪开始。如果你的报告里混用了不同货币,那就从货币追踪开始。一个 branded type、一个转换函数、一个编译期保证,往往就足以防止下一场百万美元的失误。

类型系统不会替你编写汇率逻辑、不会帮你正确处理舍入、也不会阻止你用错汇率做除法。但它能做到的是让“意外”变成“非法”。两个本不该相遇的值,如果真的相遇了,就会拒绝编译。这额外的类型开销是值得的。