Раздел 25 · Effect-TS

Schema, второй заход: рекурсия, все ошибки сразу, async-валидация, Arbitrary

senior~90 мин

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

Schema, второй заход: рекурсия, все ошибки сразу, async-валидация, Arbitrary

Сцена · форма, которая ругается по одному полю

В 02 · Schema мы научились превращать unknown в типизированное значение. Этого хватает, пока данные плоские и проверки синхронные. Дальше начинается реальность.

Первое. Пользователь заполнил форму регистрации и нажал “отправить”. Имя короткое, возраст дробный, почта без собаки. Плохой интерфейс подсветит первое поле, пользователь исправит, отправит, узнает про второе. Хороший подсветит все три сразу. Твоя схема по умолчанию ведёт себя как плохой интерфейс: она останавливается на первой ошибке.

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

Третье. Комментарий может содержать ответы, а ответы свои ответы. Тип рекурсивный, и наивная запись схемы даёт ReferenceError: Cannot access before initialization.

Четвёртое. Ты меняешь форму события, а на диске лежат сто тысяч старых записей. Читать их надо, ломать нельзя.

Все четыре решаются внутри Schema, без ухода в ручные проверки. Плюс в конце урока бонус: из схемы бесплатно получается генератор случайных значений, а значит, property-based тесты.

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

Восемь разделов:

  1. Раздел 1, рекурсивные схемы через Schema.suspend.
  2. Раздел 2, все ошибки сразу: errors: 'all' и форматирование SchemaIssue.
  3. Раздел 3, человеческие сообщения об ошибках через аннотации.
  4. Раздел 4, асинхронная валидация: SchemaGetter.checkEffect, сервис в канале схемы.
  5. Раздел 5, композиция: mapFields, fieldsAssign, три проекции одной модели.
  6. Раздел 6, значения по умолчанию и частичные документы.
  7. Раздел 7, эволюция схем и jsonb-колонки.
  8. Раздел 8, генерация значений из схемы и property-based тесты через TestSchema.

Раздел 1 · Рекурсивные схемы

Наивная запись не работает по банальной причине: константа используется до того, как её вычислили.

// ReferenceError: Cannot access 'Comment' before initialization
const Comment = Schema.Struct({
  text: Schema.String,
  replies: Schema.Array(Comment),
});

Лечится Schema.suspend, который откладывает вычисление до первого использования. Одна тонкость: TypeScript не умеет выводить рекурсивный тип сам, поэтому интерфейс приходится объявить руками.

import { Schema } from 'effect';

interface Comment {
  readonly text: string;
  readonly replies: ReadonlyArray<Comment>;
}

const Comment = Schema.Struct({
  text: Schema.String,
  replies: Schema.Array(Schema.suspend((): Schema.Codec<Comment> => Comment)),
});

Читается так: suspend принимает функцию, возвращающую схему, и вызывает её лениво. Аннотация (): Schema.Codec<Comment> обязательна, без неё компилятор уйдёт в бесконечный вывод типа.

Обрати внимание на имя типа. В v4 схема это Schema.Codec<Type, Encoded>, а не Schema.Schema<A, I>: название честнее говорит, что объект умеет в обе стороны. Хелпер Schema.Schema.Type<typeof X> для извлечения типа остался на месте.

Работает как обычная схема:

const tree = yield* Schema.decodeUnknownEffect(Comment)({
  text: 'корень',
  replies: [{ text: 'ответ', replies: [] }],
});

Если тип при кодировании отличается от типа в домене (например, дата в JSON строкой, а в домене Date), интерфейсов нужно два, для Type и для Encoded, и аннотация становится Schema.Codec<Comment, CommentEncoded>.

Куда это применяют на практике: дерево комментариев, JSON AST, дерево категорий, вложенное меню, структура прав доступа, любой формат вроде JSON Schema.

Стоит помнить про цену: рекурсивная схема снимает ограничение на глубину, поэтому вредный вход с деревом на десять тысяч уровней может уронить стек. На публичной границе имеет смысл добавить проверку глубины через Schema.check.

Раздел 2 · Все ошибки сразу

По умолчанию разбор останавливается на первой проблеме. Это разумно для внутренних вызовов и вредно для форм. Переключается опцией:

const decodeAll = Schema.decodeUnknownEffect(User, { errors: 'all' });

Теперь на входе { name: 'a', age: 200.5, email: 'нет' } в ошибке лежат все три проблемы. Осталось их прочитать.

Что именно приходит в ошибке

Разбор падает с Schema.SchemaError, и внутри у него поле issue. Это дерево из модуля SchemaIssue, где каждый узел говорит, на каком ключе и на каком правиле споткнулись.

Отдельных модулей-форматтеров в v4 нет: вместо TreeFormatter и ArrayFormatter один форматтер, который отдаёт плоский список проблем.

import { SchemaIssue } from 'effect';

const format = SchemaIssue.makeFormatterStandardSchemaV1();

for (const issue of format(error.issue).issues) {
  console.log(issue.path.join('.'), '=>', issue.message);
}
name => Expected a value with a length of at least 2
age => Expected an integer
email => Expected a string matching the pattern /@/

Каждая проблема это объект с path (массив ключей, включая индексы массивов) и message. Формат называется StandardSchemaV1 не случайно: это общий межбиблиотечный контракт ошибок валидации, тот же, что отдают zod и valibot. Значит, любая форма, которая уже умеет их показывать, поймёт и Effect без переходника.

Отсюда до собственной формы один шаг:

const toFormErrors = (error: Schema.SchemaError): Record<string, string> =>
  Object.fromEntries(
    SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues.map((issue) => [
      issue.path.join('.'),
      issue.message,
    ]),
  );

Тридцать секунд работы, и любая схема умеет подсвечивать все поля формы разом.

Для лога, где нужна не форма, а полная картина, у самой ошибки есть читаемое message с деревом: Effect.logError('внешний API сломал контракт', error) печатает его целиком.

Раздел 3 · Сообщения, понятные человеку

Expected a value with a length of at least 2 это отличное сообщение для лога и никуда не годное для пользователя. Аннотация чинит это на месте:

const Age = Schema.Number.check(
  Schema.isInt(),
  Schema.isBetween({ minimum: 0, maximum: 150 }, { message: 'возраст от 0 до 150' }),
).pipe(Schema.annotate({ message: 'возраст это целое число от 0 до 150' }));
своё сообщение = возраст это целое число от 0 до 150

Несколько правил стоит держать в голове.

Сообщение можно вешать на любой уровень: вторым аргументом конкретной проверки, через Schema.annotate на всё поле, на всю структуру. Ближайшее к месту ошибки выигрывает.

Обрати внимание, что сообщение в v4 это строка, а не функция, возвращающая строку. Обёртка была нужна для ленивости, теперь её нет.

Для мультиязычности это значит, что подстановка перевода происходит там, где схема объявляется. В нашем проекте это ложится на src/i18n/messages.ts, и схема остаётся единственным местом, где живут и правило, и текст.

Раздел 4 · Асинхронная валидация

Некоторые правила нельзя проверить, не сходив наружу: свободна ли почта, существует ли такой город, не отозван ли токен. Такая проверка это уже не фильтр, а шаг декодирования, и в v4 она так и записывается: через Schema.decode с геттером SchemaGetter.checkEffect.

import { Context, Effect, Schema, SchemaGetter } from 'effect';

class UserRepo extends Context.Service<
  UserRepo,
  { readonly isEmailTaken: (email: string) => Effect.Effect<boolean> }
>()('UserRepo') {}

const AvailableEmail = Schema.String.check(Schema.isPattern(/@/)).pipe(
  Schema.decode({
    decode: SchemaGetter.checkEffect((email) =>
      Effect.gen(function* () {
        const repo = yield* UserRepo;
        const taken = yield* repo.isEmailTaken(email);
        return taken ? `адрес ${email} уже занят` : undefined;
      }),
    ),
    encode: SchemaGetter.passthrough(),
  }),
);

Возвращаемое значение проверки читается так: undefined это “всё в порядке”, строка это сообщение об ошибке.

Обрати внимание на encode: SchemaGetter.passthrough(). Проверка асимметрична: при чтении данных снаружи в базу сходить надо, при записи наружу не надо. В v3 это было неявно, теперь обратное направление приходится назвать словом, и это к лучшему: забыть о нём стало нельзя.

Теперь самое интересное. Посмотри на тип схемы:

// Codec<string, string, UserRepo>

Третий параметр это канал требований у самой схемы. Схема, которая ходит в базу, честно объявляет, что ей нужен репозиторий, и Schema.decodeUnknownEffect(AvailableEmail)(value) вернёт эффект с UserRepo в канале R. Забыть подать сервис невозможно: не скомпилируется.

Практические следствия:

  • дешёвые синтаксические проверки ставь до дорогой асинхронной. Порядок соблюдается, и запрос в базу не полетит для строки без собаки;
  • для пачки значений используй Request и RequestResolver из 10 · Batching: сто проверок почты схлопнутся в один запрос, а схема об этом даже не узнает;
  • в тестах сервис подменяется слоем, как обычно, поэтому валидация тестируется без базы.

Раздел 5 · Одна модель, три проекции

Модель редко нужна целиком: в базе одни поля, в API другие, в форме создания третьи. Копипастить три схемы плохо, потому что они разъезжаются. Комбинаторы решают это композицией.

import { Schema, Struct } from 'effect';

const Base = Schema.Struct({ id: Schema.String, name: Schema.String });

const User = Base.pipe(Schema.fieldsAssign({ email: AvailableEmail, age: Age }));

const PublicUser = User.mapFields(Struct.omit(['email']));
const UserId = Base.mapFields(Struct.pick(['id']));

Все три операции в v4 идут через поля, а не через саму схему, и это одна идея вместо трёх отдельных комбинаторов:

  • .mapFields(f) применяет к набору полей любую функцию из модуля Struct. Отсюда Struct.pick([...]), Struct.omit([...]), Struct.assign({...}) и всё остальное, что ты уже умеешь делать с объектами.
  • Schema.fieldsAssign({...}) это короткая запись для самого частого случая, добавить поля. Заменяет Schema.extend.

Плюс такой формы в том, что старая ловушка исчезла. Раньше pick и omit возвращали схему без .fields, и взять от неё вторую проекцию было нельзя. Теперь на выходе снова полноценная структура, и проекции спокойно строятся друг от друга. Хотя привычка собирать их все от одной полной модели всё равно полезнее для чтения.

Отдельно про наследование. Иногда хочется “базовая сущность плюс специфика”, и тут есть выбор: fieldsAssign для структурного склеивания или дискриминированное объединение (Schema.Union из Schema.TaggedStruct), когда варианты по-настоящему разные. Второе почти всегда лучше: Match по _tag даёт исчерпывающие проверки, а склеивание полей их не даёт.

Раздел 6 · Значения по умолчанию и частичные документы

import { Effect, Schema } from 'effect';

const Settings = Schema.Struct({
  theme: Schema.Literals(['light', 'dark']).pipe(
    Schema.withDecodingDefaultType(Effect.succeed('light' as const)),
  ),
  retries: Schema.Number.pipe(Schema.withDecodingDefaultTypeKey(Effect.succeed(3))),
  labels: Schema.Record(Schema.String, Schema.String).pipe(
    Schema.withDecodingDefaultType(Effect.sync(() => ({}))),
  ),
  legacyName: Schema.optional(Schema.String),
});

const filled = yield* Schema.decodeUnknownEffect(Settings)({});
// { theme: 'light', retries: 3, labels: {} }

Четыре вещи, которые стоит различать:

  • Schema.optional(S) даёт A | undefined и никакого умолчания;
  • Schema.optionalKey(S) делает необязательным сам ключ, а не значение: undefined в него положить нельзя, а не написать можно;
  • Schema.withDecodingDefaultType(effect) подставляет значение, и в домене поле становится обязательным. Это то, что нужно почти всегда: код после разбора не заполнен проверками на undefined;
  • парный withDecodingDefaultTypeKey подставляет умолчание только когда ключа нет вовсе. Это бывший флаг exact: true, ставший отдельной функцией.

Заметь, что умолчание теперь это эффект, а не функция-фабрика. Для константы это Effect.succeed(3), для свежего объекта на каждый разбор Effect.sync(() => ({})). Разница та же, что была между значением и функцией, просто выражена в терминах, которые ты и так знаешь. Бонусом умолчание может быть асинхронным: сходить в конфигурацию или в кеш, если очень надо.

Раздел 7 · Эволюция схем и jsonb

Старые записи должны читаться

Формат события поменялся: было плоское поле url, стало вложенное target. На диске лежат обе версии. Приём стандартный: объединение версий плюс подъём к последней.

import { Schema, SchemaTransformation } from 'effect';

const EventV1 = Schema.Struct({ version: Schema.Literal(1), url: Schema.String });

const EventV2 = Schema.Struct({
  version: Schema.Literal(2),
  target: Schema.Struct({ url: Schema.String, name: Schema.String }),
});

const AnyEvent = Schema.Union([EventV1, EventV2]);

const Normalized = AnyEvent.pipe(
  Schema.decodeTo(
    EventV2,
    SchemaTransformation.transform({
      decode: (event) =>
        event.version === 1
          ? ({ version: 2, target: { url: event.url, name: event.url } } as const)
          : event,
      encode: (event) => event,
    }),
  ),
);
const migrated = yield* Schema.decodeUnknownEffect(Normalized)({
  version: 1,
  url: 'https://a.dev',
});
// { version: 2, target: { url: 'https://a.dev', name: 'https://a.dev' } }

Форма записи изменилась, идея нет. Schema.transform(из, в, {...}) стал из.pipe(Schema.decodeTo(в, SchemaTransformation.transform({...}))), то есть преобразование теперь читается слева направо, по ходу данных, и само правило вынесено в отдельный модуль SchemaTransformation. Если преобразование может упасть, вместо него берут пару геттеров: { decode: SchemaGetter.transformOrFail(f), encode: SchemaGetter.transform(g) }.

Ещё две мелочи из того же переезда: Schema.Union и Schema.Literals принимают массив, а не список аргументов.

Главный выигрыш в том, что весь остальной код знает только EventV2. Миграция живёт в одном месте, а не размазана по обработчикам в виде if ('url' in event).

Поле version в данных обязательно. Различать версии по наличию полей можно, но это гадание, которое ломается при первом же совпадении форм.

jsonb-колонка

В Postgres такая колонка это строка на границе и структура в домене. Schema.fromJsonString покрывает обе стороны сразу:

const SettingsColumn = Schema.fromJsonString(Settings);

const fromDb = yield* Schema.decodeUnknownEffect(SettingsColumn)(
  '{"theme":"dark","labels":{"env":"prod"}}',
);
// { theme: 'dark', retries: 3, labels: { env: 'prod' } }

const back = yield* Schema.encodeEffect(SettingsColumn)(fromDb);
// строка JSON, готовая к записи

Имя стало точнее: fromJsonString(S) это “прочитать S из JSON-строки”. Если схемы нет и нужен просто разбор произвольного JSON, есть готовый Schema.UnknownFromJsonString.

Обрати внимание, что при чтении подставились умолчания (retries: 3), а при обратном кодировании они остались в строке. То есть колонка самозалечивается: старая запись без поля после следующего сохранения станет полной.

Ровно этот приём применяется в code_submissions и подобных таблицах нашего проекта: колонка jsonb, а в коде типизированная структура со схемой на границе.

Раздел 8 · Тесты из схемы

У схемы достаточно информации, чтобы сгенерировать подходящие значения. Отсюда property-based тесты почти бесплатно.

import { Schema } from 'effect';
import { FastCheck } from 'effect/testing';

const arb = Schema.toArbitrary(User)(FastCheck);
const samples = FastCheck.sample(arb, 2);
// { name: 'bind', age: 7, email: '@' }

Генерация в v4 это метод самой схемы, Schema.toArbitrary, а не отдельный модуль. FastCheck живёт в effect/testing и отдельной зависимостью не ставится.

Самый ценный property-тест для схем это round trip: закодировали, раскодировали, получили исходное. Писать его руками больше не надо, он встроен:

import { TestSchema } from 'effect/testing';

it('encode с последующим decode возвращает исходное значение', async () => {
  const asserts = new TestSchema.Asserts(Settings);
  await asserts.verifyLosslessTransformation({ params: { numRuns: 100 } });
});

Имя verifyLosslessTransformation длинное, но точное: проверяется, что преобразование не теряет информацию. Рядом живут asserts.arbitrary().verifyGeneration() (сгенерированные значения проходят собственную схему) и обычные asserts.decoding().succeed(...) и .fail(...) для точечных случаев.

Такой тест ловит то, что примерами не поймаешь: round trip ломается на пустых строках, на нуле, на числах на границе диапазона, на датах в конце месяца.

Для генератора можно задать подсказки. Аннотация toArbitrary подменяет способ генерации для конкретного поля: например, чтобы вместо случайных строк генерировались правдоподобные адреса. Это стоит делать, когда генератор упорно попадает в неинтересные значения (как email: '@' в примере выше, формально проходящий по шаблону).

Pulse · вклад этого урока

Три изменения.

Событие получает версию и миграцию. В файле src/domain/event.ts появляется EventV1, EventV2 и NormalizedEvent. Старые JSONL-файлы читаются новым кодом, обработчики знают только последнюю версию.

Ответы внешних API разбираются с накоплением ошибок. Клиент из 19 · HttpClient и обвязка API переключается на { errors: 'all' }, а в лог кладётся весь SchemaError целиком. Когда внешний сервис сломает контракт, ты увидишь все расхождения разом, а не первое.

Конфигурация целей проверяется асинхронно. Адрес мониторинга проверяется на резолвимость DNS через SchemaGetter.checkEffect с сервисом Dns, и это требование честно всплывает в канале требований схемы:

const ResolvableUrl = Schema.String.check(
  Schema.makeFilter((value) => (URL.canParse(value) ? undefined : 'это не похоже на адрес')),
).pipe(
  Schema.decode({
    decode: SchemaGetter.checkEffect((value) =>
      Effect.gen(function* () {
        const dns = yield* Dns;
        const resolved = yield* dns.resolve(new URL(value).hostname);
        return resolved ? undefined : `домен ${value} не резолвится`;
      }),
    ),
    encode: SchemaGetter.passthrough(),
  }),
);

Порядок фильтров важен: сначала дешёвая синтаксическая проверка, потом сетевая. В тестах Dns подменяется слоем, и ни одного реального запроса не уходит.

Плюс новый тест-файл test/schema-roundtrip.test.ts с property-тестами на все доменные схемы. Он занимает двадцать строк и покрывает больше пограничных случаев, чем сорок примеров руками.

Финал · чек-лист

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

ДЗ

Все задания делаются в pulse-<nick>. После каждого открой PR с тегом lesson-21.

Дальше

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

  • 02 · Schema, первый заход. Сейчас видно, почему parse-don't-validate окупается: все сегодняшние приёмы работают только потому, что схема это единственная граница.
  • 29-testing · 15 Property-based и снапшоты, теория property-based тестирования. Schema.toArbitrary избавляет от ручного написания генераторов, но правила выбора свойств те же.