Ошибка типа на 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, объектный подход часто является правильной отправной точкой. Фантомные типы можно добавить позже.
Начните с одного инварианта
Вам не нужно моделировать каждую валюту и каждую единицу в первый день. Выберите один инвариант, который уже обжёг вас, забрендируйте его и принудите к соблюдению.
Если ваша команда допустила баг «доллары против центов», начните с отслеживания единиц. Если вы смешали валюты в отчёте, начните с отслеживания валют. Один брендированный тип, одна функция конвертации, одна гарантия времени компиляции — часто этого достаточно, чтобы предотвратить следующую миллионную ошибку.
Система типов не напишет за вас логику обменных курсов, не обработает округление правильно и не остановит вас, если вы разделите на неправильный курс. То, что она сделает — это сделает случайное нелегальным. Два значения, которые никогда не должны встретиться, откажутся компилироваться, если это произойдёт. Это стоит лишних типов.