Money: валюта как phantom-параметр
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Money: валюта как phantom-параметр
Вторая ката. Тот же приём opaque + smart-конструктор, но с новой деталью: тип параметризуется валютой, и компилятор не разрешает сложить рубли с долларами. Это полезный приём, который в DDD на классах нельзя выразить чисто, а в Gleam занимает строк двадцать.
Сцена · валютный кошмар
В отеле живут три валюты: USD, EUR, RUB. Биллинг постоянно складывает суммы: проживание + room service + mini-bar + налог. Если случайно сложить EUR с USD как обычные числа, у счёта получится бессмысленная “сумма” вроде 100, без подписи. Дальше она уйдёт в БД, в инвойс, в SAP, и через неделю гость напишет: “почему вы списали с моей карты 100 долларов, а в счёте 100 евро”.
Это не редкая ошибка. Floating-point, неявная конверсия и общий Decimal это самый частый источник денежных багов на проектах. Защищаемся типами.
Цели каты
- Узнать, что такое phantom type.
- Реализовать
Money(currency)так, чтобыadd(usd, eur)была compile-time ошибкой. - Понять, чем эта гарантия сильнее runtime-проверки (которую мы делали в Effect-треке).
К концу каты в examples/ddd-hotel/gleam/src/domain/money.gleam будет 50 строк, и три конструктора USD/EUR/RUB не путаются на типах.
Концепт · phantom type
Phantom type, это type-параметр, который не появляется в данных конструктора. Только в типе.
pub opaque type Money(currency) {
Money(amount_cents: Int)
// ^^^^^^^^^^^^^^^ только это лежит в памяти, никакой currency
}
Параметр currency это не поле, не значение, не indirect-ссылка. Это метка на уровне типа, которую компилятор использует для проверки, что две операции работают с одной валютой.
pub type Usd { Usd }
pub type Eur { Eur }
pub type Rub { Rub }
Это три отдельных типа-марки. У каждого один конструктор (Gleam требует минимум один). Сам конструктор нам не нужен, но без него Gleam не признает тип “населённым”.
Дальше связываем:
pub fn usd(cents: Int) -> Result(Money(Usd), DomainError) { ... }
pub fn eur(cents: Int) -> Result(Money(Eur), DomainError) { ... }
pub fn rub(cents: Int) -> Result(Money(Rub), DomainError) { ... }
Три функции с разными возвращаемыми типами. usd(100) даёт Money(Usd), eur(100) даёт Money(Eur). Они не взаимозаменяемы, компилятор сужает.
Языковые механики Gleam · generic types в opaque
pub opaque type Money(currency) это обычный generic-тип, только с opaque-конструктором. В пайплайне:
pub fn add(left: Money(currency), right: Money(currency)) -> Money(currency) {
Money(left.amount_cents + right.amount_cents)
}
Параметр currency появляется три раза (в обоих аргументах и в результате) и обозначает одну и ту же валюту. Если на месте left стоит Money(Usd), то и right, и результат тоже Money(Usd). Это compile-time unification.
Вызвать add(usd_value, eur_value) нельзя:
error: Type mismatch
Expected `Money(Usd)`
Found `Money(Eur)`
Это и есть главная разница с Effect/TypeScript-вариантом. В Effect мы делали sumMoney, который проверял валюту через if в рантайме и throw-ал на mismatch. В Gleam compile-time гарантия: ошибка ловится до билда, без сборки, без тестов, без CI.
Задача · сигнатуры
Открой examples/ddd-hotel/gleam/src/domain/money.gleam. Нужно объявить:
import domain/errors.{type DomainError, InvalidMoney}
pub type Usd { Usd }
pub type Eur { Eur }
pub type Rub { Rub }
pub opaque type Money(currency) {
Money(amount_cents: Int)
}
pub fn usd(cents: Int) -> Result(Money(Usd), DomainError)
pub fn eur(cents: Int) -> Result(Money(Eur), DomainError)
pub fn rub(cents: Int) -> Result(Money(Rub), DomainError)
pub fn zero_usd() -> Money(Usd)
pub fn zero_eur() -> Money(Eur)
pub fn amount_cents(money: Money(currency)) -> Int
pub fn add(left: Money(currency), right: Money(currency)) -> Money(currency)
Контракты:
- Суммы хранятся в центах (минимальной единице):
usd(100)это $1.00,usd(50)это $0.50. - Конструкторы возвращают
Error(InvalidMoney(reason))на отрицательную сумму. zero_usd()не возвращаетResult, потому что0всегда валиден.addбез проверок, потому что compile-time гарантия валюты + неотрицательность входных гарантируют неотрицательный результат.amount_centsдостаёт сырое число (например, для форматирования или записи в БД).
Решай прямо здесь: тесты прогонятся в песочнице, а подсказки и разбор ниже открывай, только если застрял.
Подсказки
- Внутренняя приватная функция
positive(cents: Int) -> Result(Money(currency), DomainError)ловит общую логику. Параметрcurrencyостаётся generic, компилятор корректно протянет его до возврата по контексту вызова. - Не пиши
subtractв этой кате. Вычитание не сохраняет неотрицательность инварианта, и требуетResult-обёртки. Это ДЗ. - Что не делаем сейчас и почему: convert, multiply, FX-rate. Это всё интересные расширения, но они отвлекают от главной идеи (phantom-параметр).
Разбор · полное решение
// examples/ddd-hotel/gleam/src/domain/money.gleam
import domain/errors.{type DomainError, InvalidMoney}
pub type Usd {
Usd
}
pub type Eur {
Eur
}
pub type Rub {
Rub
}
pub opaque type Money(currency) {
Money(amount_cents: Int)
}
pub fn usd(cents: Int) -> Result(Money(Usd), DomainError) {
positive(cents)
}
pub fn eur(cents: Int) -> Result(Money(Eur), DomainError) {
positive(cents)
}
pub fn rub(cents: Int) -> Result(Money(Rub), DomainError) {
positive(cents)
}
pub fn zero_usd() -> Money(Usd) {
Money(0)
}
pub fn zero_eur() -> Money(Eur) {
Money(0)
}
pub fn amount_cents(money: Money(currency)) -> Int {
money.amount_cents
}
pub fn add(left: Money(currency), right: Money(currency)) -> Money(currency) {
Money(left.amount_cents + right.amount_cents)
}
fn positive(cents: Int) -> Result(Money(currency), DomainError) {
case cents < 0 {
True -> Error(InvalidMoney("сумма не может быть отрицательной"))
False -> Ok(Money(cents))
}
}
Прочти ещё раз: 50 строк. Половина это объявления Usd, Eur, Rub. Сама бизнес-логика занимает строк пятнадцать. И этого достаточно для самой важной гарантии биллинга: разные валюты не складываются.
Тесты
// examples/ddd-hotel/gleam/test/money_test.gleam
import gleeunit/should
import domain/money
pub fn usd_add_test() {
let assert Ok(a) = money.usd(100)
let assert Ok(b) = money.usd(250)
money.add(a, b)
|> money.amount_cents()
|> should.equal(350)
}
pub fn eur_add_test() {
let assert Ok(a) = money.eur(100)
let assert Ok(b) = money.eur(250)
money.add(a, b)
|> money.amount_cents()
|> should.equal(350)
}
pub fn rejects_negative_test() {
money.usd(-1)
|> should.be_error()
}
pub fn zero_is_neutral_test() {
let assert Ok(value) = money.usd(500)
money.add(value, money.zero_usd())
|> money.amount_cents()
|> should.equal(500)
}
Запусти gleam test из examples/ddd-hotel/gleam. Все четыре теста зелёные.
Заметь, что в тестах нет случая “складываем разные валюты, ожидаем ошибку”. Его нельзя написать: тест не скомпилируется. Это и есть та compile-time гарантия, которая отличает Gleam-вариант от Effect-варианта.
Демонстрация compile-time guard
Создай у себя локально файл-эксперимент test/forbidden_test.gleam:
import domain/money
pub fn this_must_not_compile_test() {
let assert Ok(usd_value) = money.usd(100)
let assert Ok(eur_value) = money.eur(200)
// Раскомментируй следующую строку, попробуй запустить `gleam test`.
// Компилятор должен дать "Type mismatch" примерно такого вида:
//
// error: Type mismatch
// Expected `Money(Usd)`
// Found `Money(Eur)`
//
// money.add(usd_value, eur_value)
}
Это пример того, как через документацию-тест мы передаём студенту понимание границ системы. Удали файл после эксперимента, в финальный пакет он не идёт.
Сложи две суммы и поймай несовпадение валют сам:
addЧто увидел бы компилятор
Сумма хранится в копейках, центах, евроцентах как целое число, валюта это отдельная метка. Если метки совпадают, add возвращает Money той же валюты. Если нет, в рантайме это доменная ошибка, а в Gleam с phantom-параметром это ошибка типа: код просто не соберётся.
Критика · trade-offs
Эта реализация хороша, но не серебряная пуля. Что она не покрывает:
- Конверсию между валютами. Если бизнесу нужно “из USD в EUR”, phantom-параметр не помогает напрямую. Решение: отдельная функция
convert(money: Money(From), rate: ExchangeRate(From, To)) -> Money(To). ДЗ. - Большие числа.
Intв Gleam на BEAM это bignum, не переполнится. На target=javascript это safe int до 2^53. Для биллинга отеля более чем достаточно, для high-frequency trading подумай оgleam/erlang/atomили о специальномDecimal-пакете. - Округление. Конверсия по курсу почти всегда даёт дробное значение центов. Решение: одна явная функция округления (
round_half_evenдля соответствия IFRS), вызывается на границе. - Сериализация в JSON. Phantom-параметр стирается при кодировании. Снаружи валюта поедет как строка
"USD", на decode нужен явный матч. Решим в уроке 16 параллельного Effect-трека, в Gleam-серии это часть урока 25 (sqlite-репозиторий).
Где Gleam обыгрывает Effect
В Effect-треке мы делали:
export const sumMoney = (left: Money, right: Money): Money => {
if (left.currency !== right.currency) {
throw new Error(`currency mismatch: ${left.currency} vs ${right.currency}`);
}
return { amountCents: left.amountCents + right.amountCents, currency: left.currency };
};
Это runtime-проверка. Тест должен покрыть кейс mismatch явно. CI ловит баг, если он есть. Прод ловит баг, если CI не покрыло.
В Gleam компилятор ловит баг. Тест на mismatch написать невозможно, как мы видели. Это и есть та “невозможные состояния невыразимы”, о которой говорил Влашин: чем глубже мы засовываем правило в тип, тем меньше работы остаётся тестам и runtime-у.
Takeaway
Одна фраза, которую забираешь:
Phantom type, это бесплатный compile-time страж. Если правило выразимо в типе, не пиши его в проверке.
ДЗ
Дальше
Следующая ката · 19. Entity и Customer, identity vs equality. Меняем Value Object на Entity, и впервые встречаем понятие идентичности: два Customer-а равны не по содержимому, а по id.
Параллельно полезно перечитать:
- 04. Типы и коллекции, про generic-типы и pattern matching. Phantom-параметр это частный случай generic-типа, и владение базой делает идею прозрачной.
- Domain Modeling Made Functional, глава 5 Влашина, про “constrained types” в DDD. Та же дисциплина на F-sharp, синтаксис другой, идея одна.