Раздел 27 · Gleam на практике

Money: валюта как phantom-параметр

middle~40 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

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

Это пример того, как через документацию-тест мы передаём студенту понимание границ системы. Удали файл после эксперимента, в финальный пакет он не идёт.

Сложи две суммы и поймай несовпадение валют сам:

Money: сложение и валютаменяй валюты и смотри, что вернёт add
Money A
Money B
Ok

Что увидел бы компилятор

Сумма хранится в копейках, центах, евроцентах как целое число, валюта это отдельная метка. Если метки совпадают, 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, синтаксис другой, идея одна.