Раздел 21 · Haskell

Доменные типы на Haskell: newtype, скрытый конструктор, smart-конструкторы

senior~70 мин

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

Доменные типы на Haskell: newtype, скрытый конструктор, smart-конструкторы

Сцена · откуда тема

В коде Text это просто Text. Он ничего не знает о том, что внутри лежит идентификатор брони, номер комнаты или email гостя. Когда десять функций подряд принимают Text, ничто не мешает подставить имя гостя в позицию id брони. Компилятор промолчит, тест может не упасть, а баг всплывёт в проде после жалобы на ресепшен. То же самое с числами: Int это и номер этажа, и количество ночей, и сумма в центах.

Domain-Driven Design начинается там, где мы перестаём прятать домен за примитивами и заводим отдельный тип под каждое понятие. У отеля есть ReservationId, RoomNumber, GuestEmail, и в голове у менеджера это три разные вещи. В коде они тоже не должны путаться. Урок про то, как развести их на уровне типов, а не на уровне договорённости и code review. Инструменты ты уже видел в уроке про алгебраические типы, осталось собрать их в дисциплину.

Сквозной домен · Hotel Booking

Следующие четыре урока собирают один маленький домен бронирования отеля. Один ограниченный контекст, один агрегат Reservation, который проходит путь брони от создания до выезда. Сегодня закладываем фундамент, словарь типов.

Трек самостоятельный: теорию вводим с нуля, как будто соседние языковые треки ты не открывал. Если открывал, будет полезно: тот же домен на Rust и на Effect устроен один в один, и различия видны только в механике языка.

Что опишем сегодня:

  • ReservationId, идентификатор брони: res_ плюс не меньше восьми буквенно-цифровых символов;
  • RoomNumber, номер комнаты: три или четыре цифры;
  • GuestEmail, email гостя с простой проверкой формы;
  • Day и DateRange, интервал заезда и выезда с инвариантом check_in < check_out;
  • Money и Currency, сумма с валютой, складывать можно только одинаковые валюты.

К концу урока модуль Hotel.Domain.Types читается как единый язык домена: каждый тип это термин, у каждого термина форма и инвариант.

Ориентир · невозможные состояния невыразимы

Фраза, на которой держится весь трек: невозможные состояния невыразимы. Если в коде встречается проверка вида «а вдруг тут невалидное значение»:

-- плохо: инвариант перепроверяется в каждой функции, и однажды его забудут
nights :: Int -> Int -> Int
nights checkIn checkOut
  | checkOut > checkIn = checkOut - checkIn
  | otherwise = 0  -- как мы сюда попали? непонятно, и 0 это тихая ложь

это сигнал, что тип спроектирован неверно. Правильно нарисованный тип просто не даёт собрать плохое значение. Если DateRange валиден по построению, проверять checkIn < checkOut в каждой функции незачем: ты делаешь это один раз на границе, дальше гарантию держит тип. Приём называется parse don’t validate: парсер на входе превращает сырьё в тип, который дальше несёт доказательство своей корректности.

Раздел 1 · Newtype как номинальный тип

Инструмент в Haskell называется newtype: обёртка с одним конструктором и одним полем. На рантайме это та же строка, накладных расходов ноль, а для компилятора ReservationId и RoomNumber теперь разные типы.

newtype ReservationId = ReservationId Text
  deriving stock (Eq, Ord, Show)

unReservationId :: ReservationId -> Text
unReservationId (ReservationId t) = t

deriving stock просит компилятор вывести инстансы по обычным правилам для структуры типа. Пишем стратегию явно, потому что у newtype есть ещё deriving newtype, которая наследует поведение внутреннего типа, и молчаливый выбор между ними это источник сюрпризов.

Само по себе объявление уже кое-что даёт: функция checkIn :: ReservationId -> IO () больше не примет RoomNumber. Но пока ReservationId "мусор" собирается где угодно, гарантий у типа нет. Их даёт следующий раздел.

Раздел 2 · Скрытый конструктор это граница доверия

Ключевой ход всего урока: не экспортировать конструктор данных. В Haskell видимость управляется списком экспорта модуля. Написать ReservationId (..) значит вынести наружу и тип, и его конструктор. Написать просто ReservationId значит вынести только имя типа, а конструктор оставить внутри модуля.

module Hotel.Domain.Types
  ( -- Тип экспортируется БЕЗ (..): конструктор остаётся внутри модуля.
    ReservationId
  , mkReservationId
  , unReservationId
  , RoomNumber
  , mkRoomNumber
  , unRoomNumber
  , GuestEmail
  , mkGuestEmail
  , unGuestEmail
  ) where

Тип, у которого наружу торчит имя, но не конструктор, называется абстрактным. Снаружи про ReservationId известно только то, что он есть и что над ним есть функции. Собрать его в обход проверки нельзя, разобрать паттерн-матчингом нельзя, а внутреннее представление можно поменять с Text на ByteString, не сломав ни одного вызывающего модуля.

Единственная разрешённая дверь называется smart-конструктором:

-- ^res_[a-z0-9]{8,}$, регистронезависимо. Пишем руками, чтобы не тащить
-- зависимость от regex в учебный пример.
mkReservationId :: Text -> Either ValueError ReservationId
mkReservationId raw
  | isValid = Right (ReservationId raw)
  | otherwise = Left (InvalidReservationId raw)
  where
    lower = T.map toLower raw
    isValid = case T.stripPrefix "res_" lower of
      Nothing -> False
      Just tl ->
        T.length tl >= 8
          && T.all (\c -> isAsciiLower c || isDigit c) tl

Отсюда сильная гарантия: если у тебя на руках ReservationId, он валиден. Не «вероятно валиден», не «валиден, если кто-то не забыл проверить», а валиден по построению, потому что других дверей нет. Тип превращается в границу доверия.

Сравни с соседними треками: в Rust роль скрытого конструктора играет приватное поле структуры, в Effect это Schema.brand. Идея одна, механика разная: в Haskell границу проводит список экспорта модуля, и потому дисциплина держится на структуре проекта, а не на ключевом слове.

Остальные идентификаторы устроены так же, отличается только проверка:

-- | Номер комнаты: три или четыре цифры.
newtype RoomNumber = RoomNumber Text
  deriving stock (Eq, Ord, Show)

mkRoomNumber :: Text -> Either ValueError RoomNumber
mkRoomNumber raw
  | len >= 3 && len <= 4 && T.all isDigit raw = Right (RoomNumber raw)
  | otherwise = Left (InvalidRoomNumber raw)
  where
    len = T.length raw

-- | Email гостя: ровно один собака-символ, непустая локальная часть,
-- доменная часть с точкой и непустыми метками.
newtype GuestEmail = GuestEmail Text
  deriving stock (Eq, Ord, Show)

mkGuestEmail :: Text -> Either ValueError GuestEmail
mkGuestEmail raw
  | isValidEmail raw = Right (GuestEmail raw)
  | otherwise = Left (InvalidEmail raw)

isValidEmail :: Text -> Bool
isValidEmail raw =
  case T.splitOn "@" raw of
    [local, domain] ->
      not (T.null local)
        && not (T.null domain)
        && let labels = T.splitOn "." domain
            in length labels >= 2 && all (not . T.null) labels
    _ -> False -- ноль или больше одного разделителя

Три типа, три имени, три инварианта. Объявление каждого занимает десяток строк и закрывает целый класс ошибок навсегда.

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.