Ошибка типа на 327 миллионов долларов

В 2022 году крупная финтех-платформа обработала массовый перевод на 32 700 000,00. Сумма хранилась как обычный number. Даунстрим-сервис предполагал, что она в центах. Это было не так.

Баг пережил код-ревью, юнит-тесты и интеграционные тесты. Система типов увидела number и number — и успокоилась. Два одинаковых типа, идеально совместимые, катастрофически неверные.

Это, в двух словах, проблема валют. Ваша система типов не даст сложить строку с целым числом. Но она не помешает сложить японские йены с американскими долларами или трактовать сумму в основных единицах как сумму в мелких. Это семантически разные величины, но в TypeScript, Rust, Go и большинстве мейнстримовых языков они имеют один тип.

Почему нативные числовые типы не справляются с деньгами

number, f64, int, BigDecimal. Все они кодируют величину, а не смысл.

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.

Стандартная защита — соглашения об именовании. Назовите переменную amountInCents или amountUsd. Это работает, пока кто-то не сделает рефакторинг, не скопирует значение через границу или просто не перепутает имя. Комментарии и соглашения об именовании не поддаются принуждению.

Вам нужен тип, который кодирует и числовое значение, и валютную единицу. Компилятор должен отклонить USD + EUR так же, как он отклоняет string + number.

Фантомные типы: учим компилятор различать валюты

Техника, которая работает, называется фантомным типом. Вы определяете обёртку вокруг числа, где параметр типа несёт метку валюты. Параметр типа никогда не появляется в рантайме, но компилятор использует его для принуждения к соблюдению ограничений.

Вот полная, работающая реализация на 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 создаёт номинальное различие типов. Два значения 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">

Попробуйте использовать неправильный курс — и компилятор остановит вас. Поменяйте from и to в определении курса — и каждый вызов с этим курсом станет ошибкой типа. Баг пойман ещё до коммита.

Ловушка основных единиц против мелких

Валюта — не единственное измерение, которое можно моделировать. Баг финтеха 2022 года был не несоответствием валют. Это было несоответствие единиц: доллары против центов.

Фантомные типы справляются и с этим. Добавьте второй параметр типа для единицы.

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 и поддающееся ревью.

Где этот паттерн реально больно бьёт

Фантомные типы не бесплатны. Они добавляют трения в повседневные операции и не решают все проблемы с деньгами.

Сериализация — первая боль. JSON не знает о брендированных типах. Когда вы делаете JSON.stringify с Money<"USD">, вы получаете обычное число. Когда парсите обратно — у вас обычное число. Вы должны валидировать и перебрендировать на каждой границе.

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);
}

Сторонние библиотеки — вторая боль. Большинство математических, форматирующих и баз данных библиотек ожидают обычный number или Decimal. Вы потратите время на написание адаптеров или обёрток.

Плавающая точка — третья. Примеры выше используют number, а значит 0.1 + 0.2 !== 0.3. Для финансового софта это неприемлемо. Храните деньги как целое число мелких единиц или используйте нормальную decimal-библиотеку, и брендируйте уже её вместо number.

import { Decimal } from "decimal.js";

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

Более простая альтернатива: просто используйте объекты

Если фантомные типы кажутся оверкиллом, обычный объект с рантайм-валидацией даст вам большую часть безопасности с меньшим типовым механизмом.

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, объектный подход часто является правильной отправной точкой. Фантомные типы можно добавить позже.

Начните с одного инварианта

Вам не нужно моделировать каждую валюту и каждую единицу в первый день. Выберите один инвариант, который уже обжёг вас, забрендируйте его и принудите к соблюдению.

Если ваша команда допустила баг «доллары против центов», начните с отслеживания единиц. Если вы смешали валюты в отчёте, начните с отслеживания валют. Один брендированный тип, одна функция конвертации, одна гарантия времени компиляции — часто этого достаточно, чтобы предотвратить следующую миллионную ошибку.

Система типов не напишет за вас логику обменных курсов, не обработает округление правильно и не остановит вас, если вы разделите на неправильный курс. То, что она сделает — это сделает случайное нелегальным. Два значения, которые никогда не должны встретиться, откажутся компилироваться, если это произойдёт. Это стоит лишних типов.