Раздел 25 · Effect-TS

DDD-типы: branded, opaque, smart constructors на Schema

middle-senior~60 мин

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

DDD-типы: branded, opaque, smart constructors на Schema

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

В TypeScript строка это просто строка. string ничего не знает про “это email”, “это id брони”, “это название города”. Когда у тебя в коде десять функций принимают string, ничто не мешает подставить customerName в позицию productId. Компилятор молчит, тест может не упасть. Баг ловится в проде, обычно в логе после жалобы пользователя.

DDD начинается там, где мы перестаём играть в “у меня тут просто строка” и начинаем говорить языком домена. У отеля есть ReservationId, RoomNumber, GuestEmail. Эти три понятия живут в разных частях головы у менеджера ресепшен и не должны путаться у нас в коде. Этот урок про то, как сделать так, чтобы они не путались на уровне типов, а не “на уровне договорённости и code review”.

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

Следующие четыре урока (14-17) собирают одно: маленький Hotel Booking домен на Effect. Один bounded context, один агрегат, один Decider. Сегодня закладываем фундамент: словарь типов.

Что нам нужно описать:

  • ReservationId, идентификатор брони. Формат res_<8 символов>.
  • RoomNumber, номер комнаты. Три или четыре цифры.
  • GuestEmail, email гостя. Должен пройти простую регулярку.
  • DateRange, интервал заезда и выезда. Инвариант: checkIn < checkOut.
  • Money, сумма с валютой. Инвариант: складывать можно только в одной валюте.

К концу урока в examples/ddd-hotel/effect/src/domain/types.ts будет полный набор, и тестовый файл, который пробует подсунуть невалидные значения и видит понятные ошибки. Декларацию можно открыть и читать как ubiquitous language: каждый тип это термин, у каждого термина есть форма и инвариант.

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

Запомни одну фразу. Невозможные состояния невыразимы. Если ты видишь в коде проверку вида:

if (reservation.checkIn !== null && reservation.checkOut !== null && reservation.checkIn < reservation.checkOut) {
  // дальше работаем
} else {
  // как мы сюда попали? непонятно
}

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

То же самое мы видели в 02-schema под именем parse-don’t-validate. Сейчас тот же приём, только мы целенаправленно используем его для DDD-словаря, а не для конфига.

Карта урока · что заберёшь домой

  1. Branded типы. Зачем нужны, чем отличаются от обычной строки, как делаются через Schema.brand.
  2. Smart constructors. Один синхронный конструктор .make ловит невалидное значение прямо в коде.
  3. Составные инварианты через Schema.makeFilter. Когда инвариант не про отдельное поле, а про пару (checkIn < checkOut).
  4. Branded Currency и Money. Как сделать так, чтобы сумма EUR и USD не собиралась.
  5. Tagged errors как канал отказа парсинга. Куда складывать причину “не сошлось”.
  6. Reservation-домен целиком. Собираем фундамент, на котором поедет урок 15.

Раздел 1 · Branded типы

Сцена

У отеля есть два числовых идентификатора: RoomNumber (например, "101") и FloorNumber (например, 1). Без branded типов оба сидят как string и number. В функции getRoomsOnFloor(floor: number) можно случайно подставить room.floor. Компилятор пропустит. Если потом ты в room.floor начнёшь класть RoomNumber (рефакторинг, новая фича), тест может пройти, а в проде покажется чужой номер.

Идея словами

Branded тип это номинальный тип поверх примитива. На уровне рантайма это всё та же строка или число, на уровне типов TS видит фантомное поле, которое нельзя проставить снаружи. Получается дешёвое (нулевые накладные расходы) и сильное разделение типов.

Шаг 1 · Базовое branded-объявление

import { Schema } from 'effect';

export const ReservationId = Schema.String.check(
  Schema.isPattern(/^res_[a-z0-9]{8,}$/i, { title: 'ReservationId' }),
).pipe(Schema.brand('ReservationId'));
export type ReservationId = Schema.Schema.Type<typeof ReservationId>;
//          ^? string & Brand<'ReservationId'>

Что тут произошло:

  • Schema.String базовый тип.
  • .check(Schema.isPattern(...)) проверка формата на decode и на .make. Метод .check есть у любой схемы и принимает сколько угодно предикатов через запятую.
  • Schema.brand('ReservationId') навешивает фантомное поле Brand<'ReservationId'>. Бренд не предикат, а трансформация типа, поэтому он идёт через .pipe, а не через .check.

Обрати внимание на порядок: сначала .check(...) с проверками, потом .pipe(Schema.brand(...)). Аннотация фильтра называется title, она подписывает именно упавшую проверку в сообщении об ошибке.

После этого ReservationId это не string. Подсунуть "hello" в позицию ReservationId компилятор не разрешит.

Шаг 2 · Smart constructor

Schema.brand экспортирует синхронный конструктор .make:

const id = ReservationId.make('res_abcd1234');
// id: ReservationId

const broken = ReservationId.make('hello');
// бросит на месте: pattern не сошлось

.make запускает все проверки синхронно. Если значение валидно, получаешь branded экземпляр. Если нет, исключение прямо на месте, до того как невалидный id уйдёт в decider. Это полезно в фабриках событий, генераторах тестовых данных, миграциях.

Шаг 3 · Где это видно в типе

declare function loadReservation(id: ReservationId): Effect.Effect<Reservation>;

const raw: string = 'res_abcd1234';
loadReservation(raw);
//              ^^^ Argument of type 'string' is not assignable to parameter
//                  of type 'string & Brand<"ReservationId">'

loadReservation(ReservationId.make(raw));
// ok

Тип Brand<'ReservationId'> не сконструируется обычной литеральной строкой. Единственный способ его получить, это пройти через декод или .make. Это и есть та граница доверия, о которой говорит DDD.

Шаг 4 · То же самое для остальных id

export const RoomNumber = Schema.String.check(
  Schema.isPattern(/^[0-9]{3,4}$/, { title: 'RoomNumber' }),
).pipe(Schema.brand('RoomNumber'));
export type RoomNumber = Schema.Schema.Type<typeof RoomNumber>;

export const GuestEmail = Schema.String.check(
  Schema.isPattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, { title: 'GuestEmail' }),
).pipe(Schema.brand('GuestEmail'));
export type GuestEmail = Schema.Schema.Type<typeof GuestEmail>;

Три branded типа, три имени, три инварианта. Подставить RoomNumber в позицию GuestEmail нельзя. Подставить голую строку нельзя ни в какую из этих позиций. Декларация занимает шесть строк, а ловит целый класс ошибок раз и навсегда.

Что взять с собой

  • Branded тип это TS-конструкция: примитив + фантомное поле, несовместимое с другим именем. На рантайме это просто строка или число.
  • Schema.brand('Name') даёт smart constructor .make и интегрируется с decodeUnknownEffect.
  • Branded типы это первый и самый дешёвый шаг ubiquitous language: каждый id в домене имеет своё имя.

Раздел 2 · Составные инварианты через Schema.makeFilter

Сцена

Не каждый инвариант это “одно поле, один паттерн”. Часть инвариантов про пару полей: checkIn < checkOut, discount <= price, endsAt > startsAt. Это и есть тот случай, когда без собственного фильтра поверх структуры пришлось бы городить проверку в каждой функции.

Идея словами

Готовые предикаты (isPattern, isInt, isMinLength) закрывают типовые случаи. Когда правило своё, его заворачивают в Schema.makeFilter(predicate) и вешают тем же методом .check. Если фильтр стоит над Schema.Struct, ты получаешь доступ ко всей форме структуры и можешь сравнивать поля между собой. Предикат возвращает true или строку с сообщением.

Шаг 1 · DateRange с инвариантом

export const Day = Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)).pipe(
  Schema.brand('Day'),
);
export type Day = Schema.Schema.Type<typeof Day>;

export const DateRange = Schema.Struct({
  checkIn: Day,
  checkOut: Day,
}).check(
  Schema.makeFilter((range) =>
    range.checkIn < range.checkOut
      ? true
      : `checkIn (${range.checkIn}) должен быть строго меньше checkOut (${range.checkOut})`,
  ),
);
export type DateRange = Schema.Schema.Type<typeof DateRange>;

Что важно:

  • Day это branded число (UTC-эпоха в миллисекундах или количество дней с epoch, неважно, главное что номинальное). Два предиката перечислены через запятую в одном .check, отдельный вызов на каждый не нужен.
  • DateRange это Schema.Struct плюс собственный фильтр, поэтому проверка пары полей живёт внутри схемы.
  • Попытка собрать DateRange.make({ checkIn: Day.make(10), checkOut: Day.make(5) }) упадёт на месте.

Шаг 2 · Почему не “просто проверить в коде”

Если бы мы оставили DateRange как обычную Schema.Struct без фильтра, инвариант пришлось бы проверять в каждом месте: в placeReservation, в extendStay, в validateOnUI. Три места, три копии правила, три потенциальных бага.

С .check правило живёт в одной точке. После decodeUnknownEffect или .make мы знаем, что checkIn < checkOut. Никаких лишних проверок, типы держат гарантию.

Шаг 3 · Декод-ошибка с понятным сообщением

const result = Schema.decodeUnknownResult(DateRange)({
  checkIn: 100,
  checkOut: 50,
});
// Result.fail(SchemaError: "checkIn (100) должен быть строго меньше checkOut (50)")

Заметь, какой контейнер выбран: у декодеров в Effect имя явно называет результат. decodeUnknownEffect даёт Effect, decodeUnknownResult даёт синхронный Result, decodeUnknownExit даёт Exit. Безымянного варианта нет, гадать не приходится.

Сообщение из фильтра попадает в SchemaError. На границе с HTTP-сервером это сразу станет 400 с человеко-читаемым “вот что не так”. Не нужно ловить, переводить, маппить, оно уже сформулировано на доменном языке.

Что взять с собой

  • .check(...) ставит проверку над любой схемой, включая структуры с пересечениями полей.
  • Свой предикат заворачивается в Schema.makeFilter, готовые лежат рядом под именами is*.
  • Инвариант на паре полей живёт в одной точке (схеме), а не в каждом вызывающем коде.
  • Сообщение из фильтра попадает в SchemaError и автоматически становится частью UX на границе.

Раздел 3 · Branded Currency и Money

Сцена

В отеле есть три валюты: USD, EUR, RUB. Внутри биллинга мы постоянно складываем суммы: начисления за номер, мини-бар, обслуживание номеров. Если случайно сложить EUR и USD как числа, у тебя в счёте окажется бессмысленная сумма “100”, без подписи. Биллинг это место, где такая ошибка ведёт к реальным деньгам и реальному жалующемуся гостю.

Идея словами

Делаем Currency branded literal-union, а Money это пара { amountCents, currency }. Дальше пишем sumMoney, который явно проверяет совпадение валют, и падает (или возвращает Result), если они разные. Между разными валютами нет числовой операции.

Шаг 1 · Currency

export const Currency = Schema.Literals(['USD', 'EUR', 'RUB']).pipe(Schema.brand('Currency'));
export type Currency = Schema.Schema.Type<typeof Currency>;
//          ^? ('USD' | 'EUR' | 'RUB') & Brand<'Currency'>

Schema.Literals(['USD', 'EUR', 'RUB']) это уже union трёх строк, бренд добавляет номинальность сверху. Случайно подставить 'GBP' нельзя ни в decode, ни в .make. Для одного литерала есть Schema.Literal('USD'), для набора нужен Schema.Literals и массив: два разных имени вместо одного вариадического.

Шаг 2 · Money

export const Money = Schema.Struct({
  amountCents: Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)),
  currency: Currency,
});
export type Money = Schema.Schema.Type<typeof Money>;

amountCents хранится в минимальной единице (центы, копейки). Дробных долларов в коде не существует, ровно по той же причине, по которой Stripe API делает то же самое: floating-point складывать опасно, целые цента нет.

Шаг 3 · sumMoney с проверкой валюты

export const sumMoney = (a: Money, b: Money): Money => {
  if (a.currency !== b.currency) {
    throw new Error(`currency mismatch: ${a.currency} vs ${b.currency}`);
  }
  return { amountCents: a.amountCents + b.amountCents, currency: a.currency };
};

В этой версии проверка рантаймовая. Этого достаточно, если все вызовы проходят через sumMoney. Если хочется compile-time гарантию, можно сделать Money дженериком по валюте, чтобы Money<'USD'> и Money<'EUR'> были разными типами, и sumMoney принимал бы только два Money<C> с одинаковым параметром. Это даёт более сильную гарантию, но усложняет JSON и декод. В нашем домене мы оставляем рантайм-проверку, потому что валюта в основном приходит из БД или API, и compile-time дженерик там быстро превращается в Money<string>.

Шаг 4 · Тест

import { describe, expect, it } from 'vitest';

import { Currency, Money, sumMoney } from '../src/domain/types.ts';

describe('Money', () => {
  it('складывает две суммы в одной валюте', () => {
    const a: Money = { amountCents: 100, currency: Currency.make('USD') };
    const b: Money = { amountCents: 250, currency: Currency.make('USD') };
    expect(sumMoney(a, b)).toEqual({ amountCents: 350, currency: 'USD' });
  });

  it('падает на разных валютах', () => {
    const a: Money = { amountCents: 100, currency: Currency.make('USD') };
    const b: Money = { amountCents: 250, currency: Currency.make('EUR') };
    expect(() => sumMoney(a, b)).toThrow('currency mismatch');
  });
});

Два теста закрывают полную семантику: один проверяет happy path, второй фиксирует контракт “ловим mismatch до того, как он уйдёт в БД”.

Что взять с собой

  • Branded Currency плюс структурный Money дают безопасное представление.
  • Хранение в центах убирает класс ошибок с floating point.
  • Compile-time гарантия совпадения валют возможна через generic Money<C>, но это компромисс с удобством JSON-кодирования.

Раздел 4 · Tagged errors на границе парсинга

Сцена

Schema.decodeUnknownEffect возвращает Effect<A, SchemaError, never>. На границе с доменным кодом нам обычно нужна своя ошибка, а не SchemaError. Причина: вызывающий пишет Effect.catchTag('InvalidReservationCommand', ...), и хочет видеть знакомое имя, а не “что-то от Schema”.

Идея словами

Используем уже знакомый Data.TaggedError (см. 03-errors) для собственных ошибок, и оборачиваем SchemaError через Effect.mapError. На входе в decider всегда лежит уже типизированное значение, и вызывающий уже знает, что декод прошёл.

Шаг 1 · Объявление

import { Data } from 'effect';

export class InvalidReservationInput extends Data.TaggedError('InvalidReservationInput')<{
  readonly field: string;
  readonly cause: unknown;
}> {}

InvalidReservationInput это специфичная для домена ошибка. У неё свой _tag, и catchTag('InvalidReservationInput', ...) сужается до неё.

Шаг 2 · Оборачивание SchemaError

import { Effect, Schema } from 'effect';

import { PlaceReservation } from './commands.ts';

export const parseCommand = (raw: unknown) =>
  Schema.decodeUnknownEffect(PlaceReservation)(raw).pipe(
    Effect.mapError(
      (cause) => new InvalidReservationInput({ field: 'PlaceReservation', cause }),
    ),
  );
// parseCommand: (raw) => Effect<PlaceReservation, InvalidReservationInput>

Канал ошибки Effect<..., SchemaError> мы заменили на Effect<..., InvalidReservationInput>. Вызывающий теперь видит доменную ошибку, а не ошибку Schema.

Шаг 3 · Почему не наоборот

Можно было бы сделать InvalidReservationInput extends Data.TaggedError, который внутри хранит SchemaError как cause. Это вариант “тонкая обёртка”. Так и сделано в примере выше: cause: unknown хранит исходный SchemaError (а в нём лежит поле issue с деревом разбора), и при логировании или в Sentry мы можем добавить детали. Доменный код опирается на тег, инфраструктурный код может вытащить cause для диагностики.

Что взять с собой

  • На границе домена SchemaError оборачиваем в Data.TaggedError.
  • Тег у ошибки специфичен для домена (InvalidReservationInput, InvalidMoney, …).
  • Исходную ошибку кладём в cause, она нужна для логов, но не для бизнес-логики.

Раздел 5 · Reservation-домен целиком

Сцена

Сложим всё вместе. К концу урока у тебя в examples/ddd-hotel/effect/src/domain/types.ts лежит полный словарь. На нём поедут команды (урок 15), события (урок 15), Decider (урок 15), Event Store (урок 16), проекции (урок 17).

Финальный файл

// examples/ddd-hotel/effect/src/domain/types.ts
import { Schema } from 'effect';

export const ReservationId = Schema.String.check(
  Schema.isPattern(/^res_[a-z0-9]{8,}$/i, { title: 'ReservationId' }),
).pipe(Schema.brand('ReservationId'));
export type ReservationId = Schema.Schema.Type<typeof ReservationId>;

export const RoomNumber = Schema.String.check(
  Schema.isPattern(/^[0-9]{3,4}$/, { title: 'RoomNumber' }),
).pipe(Schema.brand('RoomNumber'));
export type RoomNumber = Schema.Schema.Type<typeof RoomNumber>;

export const GuestEmail = Schema.String.check(
  Schema.isPattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, { title: 'GuestEmail' }),
).pipe(Schema.brand('GuestEmail'));
export type GuestEmail = Schema.Schema.Type<typeof GuestEmail>;

export const Day = Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)).pipe(
  Schema.brand('Day'),
);
export type Day = Schema.Schema.Type<typeof Day>;

export const DateRange = Schema.Struct({
  checkIn: Day,
  checkOut: Day,
}).check(
  Schema.makeFilter((range) =>
    range.checkIn < range.checkOut
      ? true
      : `checkIn (${range.checkIn}) должен быть строго меньше checkOut (${range.checkOut})`,
  ),
);
export type DateRange = Schema.Schema.Type<typeof DateRange>;

export const Currency = Schema.Literals(['USD', 'EUR', 'RUB']).pipe(Schema.brand('Currency'));
export type Currency = Schema.Schema.Type<typeof Currency>;

export const Money = Schema.Struct({
  amountCents: Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)),
  currency: Currency,
});
export type Money = Schema.Schema.Type<typeof Money>;

export const sumMoney = (a: Money, b: Money): Money => {
  if (a.currency !== b.currency) {
    throw new Error(`currency mismatch: ${a.currency} vs ${b.currency}`);
  }
  return { amountCents: a.amountCents + b.amountCents, currency: a.currency };
};

Это весь словарь. Открой файл и прочти как ubiquitous language: ReservationId, RoomNumber, GuestEmail, Day, DateRange, Currency, Money. Семь терминов, у каждого свой инвариант. Декларация занимает меньше 60 строк. Дальше любой код домена опирается на этот словарь, и его контракт сразу понятен.

Чек-лист

Чек-листготово

ДЗ

Сделай все четыре в examples/ddd-hotel/effect/ или в своём форке. После каждого открой PR с тегом lesson-14.

Дальше

Следующий урок · 15. Decider pattern на Effect.gen. На словарь из этого урока садятся команды и события, и появляется канон Жереми Шассена: decide, evolve, initial. К концу урока 15 у тебя будет работающий BDD-тест Reservation FSM без единой строчки persistence.

Параллельно полезно перечитать:

  • 02-schema, про Schema.brand, фильтры через .check и Schema.decodeTo. Этот урок надстроен над тем.
  • 03-errors, про Data.TaggedError и канал E. На границе домена мы оборачиваем SchemaError в свою tagged-ошибку, и это работает ровно так, как там описано.