Раздел 25 · Effect-TS

Schema: parse-don't-validate, branded типы, transform

middle~130 мин

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

Schema: parse-don’t-validate, branded типы, transform

Сцена · входная дверь

Граница доверия. Снаружи в программу заходит JSON из файла, тело HTTP-запроса, переменные окружения, ответ внешнего API. Внутри работает типизированный код, который опирается на инварианты: “тут лежит валидный URL”, “тут число от 1 до 60”, “тут не пустая строка”. Между этими двумя мирами стоит входная дверь, и Schema это её механизм.

Та же дисциплина, что в 04-ts/04 · Reliability and types. Там был zod, тут effect/Schema. Идея одна: parse-don’t-validate. Один раз на входе, дальше типы держат гарантии.

Разница в том, что Effect-Schema это не “ещё одна валидационная либа”. У схемы внутри две стороны, и обе декларируются одним описанием.

Сцена · две стороны одной схемы

Запиши два соседних слова: encoded и decoded. Сырая форма (то, что в JSON) и богатая форма (то, чем пользуется код). Между ними стрелки в обе стороны:

   {"interval":"30s"}            { interval: Duration(30 000 ms) }
   ┌──────────────────┐  decode  ┌──────────────────────────────┐
   │     Encoded      │ ───────► │             Type             │
   │  string · число  │          │  Date · Duration · Brand<>  │
   │  null · plain    │ ◄─────── │  Option · Result · класс    │
   └──────────────────┘  encode  └──────────────────────────────┘

decode принимает сырое значение и при успехе возвращает богатое. encode забирает богатое и возвращает сырое. Одна схема описывает оба перехода, без двух копий кода. В этом и сила одной декларации: ты не пишешь сериализатор отдельно от десериализатора, ты пишешь правило соответствия один раз.

В частном случае, когда обе стороны совпадают по типу (string -> string), encode и decode становятся тождеством, и схема работает как чистый валидатор. В общем случае, когда сырое значение не совпадает с богатым (строка '30s' -> Duration), мы пишем transform или transformOrFail, и они автоматически дают двунаправленность.

Сквозной проект · Pulse · что доедет в этот раз

В уроке 01 · Effect intro у тебя в pulse-<nick> лежали два файла, src/probe.ts и src/index.ts. URL приходил в process.argv руками, никакого конфига не было. В этом уроке появляется pulse.config.json со списком адресов и интервалами:

{
  "monitors": [
    {
      "id": "github-www",
      "url": "https://github.com",
      "interval": "30s",
      "expect": { "status": 200 }
    },
    {
      "id": "self-api",
      "url": "https://api.self.local/health",
      "interval": "1m"
    }
  ]
}

К концу урока в src/config.ts будет схема PulseConfig, в которой:

  • MonitorId, branded строка с проверкой формата. Случайно подставить туда обычный string нельзя.
  • Url, branded строка с проверкой схемы (http:// или https://). Перепутать с произвольной строкой нельзя.
  • Interval, branded number (миллисекунды) с биндингом из строки '30s' через Schema.decodeTo.
  • Expect, optional объект с дефолтом { status: 200 }.
  • MonitorEvent, размеченное объединение из четырёх веток (успешная проверка, провал проверки, остановка монитора, возобновление). Это словарь событий Pulse: каждая проверка адреса порождает ровно одну такую запись, дальше мы складываем её в локальный журнал и рассылаем подписчикам, чтобы из этого потока потом строить живую таблицу в терминале и SSE для браузера. Полностью разберём в уроке 03 · Tagged-ошибки вместе с семейством tagged-ошибок.

Одна команда Schema.decodeUnknownEffect(PulseConfig)(rawJson) либо вернёт строго типизированный конфиг, либо отдаст структурированную ошибку парсинга, на которую можно навести курсор и увидеть, где именно сломалось.

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

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

  1. Decode и Schema.Struct. Базовые типы и decodeUnknownEffect, Encoded vs Type на простом случае.
  2. Encode на практике. Почему Option и Union в нём интереснее всего, TaggedStruct для размеченных объединений.
  3. Двусторонние трансформации. Schema.decodeTo вместе с SchemaTransformation.transform и SchemaGetter.transformOrFail, парсим '30s' в Duration и обратно.
  4. Проверки через .check(...). Разница между проверкой и Schema.brand.
  5. Композиция. fieldsAssign, mapFields, decodeTo, большие схемы из маленьких.
  6. Проекции. toType и toEncoded, когда нужна только одна сторона.
  7. Прощающие схемы. optionalKey, withDecodingDefaultType, NullOr, withConstructorDefault.
  8. Эффектные трансформации. Геттер, который умеет тянуть зависимости: декод, который требует Clock или HttpClient.
  9. Opaque types. Schema.brand и Schema.Class, nominal typing без своего класса и со своим классом.
  10. Pulse · вклад этого урока. src/config.ts целиком: Url, Interval, MonitorId, Monitor, PulseConfig и decodeFromFile. Сервис Config поверх этой схемы соберём в 07 · Координация через Deferred.

Раздел 1 · Decode и Schema.Struct

Сцена

Самый частый сценарий. У тебя в руках unknown (то, что вернул JSON.parse), и нужно убедиться, что внутри лежит ожидаемая форма. Без Schema этот сценарий выглядит как лестница из if (typeof x === 'object' && x !== null && 'foo' in x && typeof x.foo === 'string'), и каждая такая лестница, это ручной парсер. Переписывать его в каждом коде глупо. Schema.Struct это декларация формы, и компилятор сам выводит из неё тип.

Идея словами

Базовые конструкторы из effect/Schema:

КонструкторТип на выходе
Schema.Stringstring
Schema.Numbernumber
Schema.Booleanboolean
Schema.Nullnull
Schema.Literals(['a', 'b'])'a' | 'b'
Schema.Array(Schema.X)X[]
Schema.Record(key, value)Record<K, V>
Schema.Struct({ ... }){ ... }

Заметь форму аргументов: Literals, Union и Tuple принимают массив вариантов, а не список через запятую. Это чинит вывод типов на больших объединениях и делает схему обычным значением, которое удобно собирать программно.

Из них собираешь дерево, передаёшь в Schema.decodeUnknownEffect(s)(value) и получаешь Effect<A, SchemaError, never>. Если нужна синхронная версия, есть Schema.decodeUnknownSync(s), она бросает исключение при провале (полезно в тестах и в скриптах запуска, где упасть при старте, это и есть нужное поведение).

Шаг 1 · Самый простой случай

import { Schema } from 'effect';

const Monitor = Schema.Struct({
  id: Schema.String,
  url: Schema.String,
  interval: Schema.String,
});

type Monitor = Schema.Schema.Type<typeof Monitor>;
//   ^? { readonly id: string; readonly url: string; readonly interval: string }

Schema.Schema.Type<typeof Monitor> достаёт богатый тип. На этом этапе он совпадает с сырым: внутри только примитивы. Зато имя Monitor теперь существует и в рантайме (как схема) и на типах (как тип значения).

Шаг 2 · Decode

import { Effect } from 'effect';

const program = Effect.gen(function* () {
  const raw: unknown = JSON.parse('{"id":"a","url":"https://x","interval":"30s"}');
  const monitor = yield* Schema.decodeUnknownEffect(Monitor)(raw);
  return monitor;
});

const value = await Effect.runPromise(program);
// value: Monitor

Schema.decodeUnknownEffect(s)(input) возвращает Effect<A, SchemaError, never>. Суффикс Effect в имени не украшение: в Schema есть целое семейство точек входа, и каждая говорит, в какой обёртке отдаёт результат. decodeUnknownExit(s) вернёт Exit, decodeUnknownSync(s) вернёт голое значение и бросит исключение при провале. На границе с Effect-кодом удобнее decodeUnknownEffect, чтобы ошибки потекли в общий канал E.

Шаг 3 · Чтение ошибки

Сломаем тип специально:

import { Result } from 'effect';

const bad = await Effect.runPromise(
  Effect.result(
    Schema.decodeUnknownEffect(Monitor)({ id: 1, url: 'https://x', interval: '30s' }),
  ),
);
// bad: Result<Monitor, SchemaError>
// Result.isFailure(bad) === true
// bad.failure: SchemaError('Expected string, actual 1 at ["id"]')

Пара имён, которую стоит запомнить сразу. Контейнер “или успех, или ошибка” в Effect зовут Result (успех лежит в .success, провал в .failure, проверяются через Result.isSuccess и Result.isFailure), а Effect.result это то, что превращает провал Effect-а в такое значение вместо короткого замыкания.

Провал парсинга это SchemaError, и внутри у него не строка, а структурированный SchemaIssue в поле issue. Разложить его в плоский список проблем с путём до сломанного поля можно так:

import { SchemaIssue } from 'effect';

if (Result.isFailure(bad)) {
  console.error(SchemaIssue.makeFormatterStandardSchemaV1()(bad.failure.issue).issues);
  // [ { path: ['id'], message: 'Expected string, actual 1' } ]
}

Шаг 4 · Encoded vs Type на одном экземпляре

Каждая схема имеет две стороны. Достаются они так:

type MonitorEncoded = Schema.Schema.Encoded<typeof Monitor>;
//   ^? { readonly id: string; readonly url: string; readonly interval: string }
type MonitorType = Schema.Schema.Type<typeof Monitor>;
//   ^? { readonly id: string; readonly url: string; readonly interval: string }

В простом случае они совпадают. Как только мы добавим branded-типы (Url, Interval), Type обогатится, а Encoded останется голым JSON-совместимым. Эту разницу разглядим в Разделе 3 на transform.

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

  • Schema.Struct({...}) декларирует форму один раз, типы выводятся.
  • decodeUnknownEffect возвращает Effect, на границе со внешним миром это естественный канал.
  • У каждой схемы две стороны, Encoded и Type. Пока преобразований нет, они одинаковые.

Раздел 2 · Encode на практике, Options и Unions

Сцена

Decode понятно зачем: пришёл JSON, проверил, поехали. А зачем нужен encode? Затем, что часто ту же сущность надо положить обратно: записать в файл, отправить наружу через HTTP, сложить в БД. Если у тебя есть одна схема, тебе не нужен второй сериализатор. Один и тот же Schema.Struct отдаёт и парсер, и сериализатор.

const monitor: Monitor = { id: 'a', url: 'https://x', interval: '30s' };
const json = Schema.encodeSync(Monitor)(monitor);
// json: { id: 'a', url: 'https://x', interval: '30s' }, готово к JSON.stringify

Для скучных схем (только примитивы) encode тривиален и совпадает по форме с decode. Самое интересное начинается там, где сырой формат и богатый тип расходятся: Option и Union.

Шаг 1 · Option на encoded-стороне

В сыром JSON нет типа Option. Есть null, есть отсутствие ключа. На стороне Type нам удобнее работать с Option<X> (без null-ловушек). Schema умеет связывать одно с другим:

import { Option } from 'effect';

const ExpectStatus = Schema.OptionFromOptionalKey(Schema.Number);

const Monitor = Schema.Struct({
  id: Schema.String,
  url: Schema.String,
  expectStatus: ExpectStatus,
});

type MonitorType = Schema.Schema.Type<typeof Monitor>;
//   ^? { readonly id: string; readonly url: string; readonly expectStatus: Option<number> }
type MonitorEncoded = Schema.Schema.Encoded<typeof Monitor>;
//   ^? { readonly id: string; readonly url: string; readonly expectStatus?: number }

Заметь разницу: на сыром уровне expectStatus?: number (ключ может отсутствовать), на богатом уровне Option<number> (явно один из двух конструкторов). Encode превратит Option.none() в “ключа нет”, Option.some(200) в expectStatus: 200.

Это и есть encode для Option. Без библиотеки тебе пришлось бы руками выбирать соглашение, “пустота это null или отсутствие ключа”, и руками же его поддерживать в обе стороны. Schema выбирает за тебя по конструктору, который ты явно указал. Соседей у OptionFromOptionalKey целое семейство, и имя каждого читается как описание сырой стороны: OptionFromNullOr (в JSON лежит null), OptionFromUndefinedOr, OptionFromNullishOr, OptionFromOptional (ключа нет либо в нём undefined).

Шаг 2 · Discriminated union через TaggedStruct

Тэг-уний это рабочая лошадка любой системы событий. Pulse будет складывать в журнал поток вида:

import { Schema } from 'effect';

const ProbeSuccess = Schema.TaggedStruct('ProbeSuccess', {
  monitorId: Schema.String,
  url: Schema.String,
  status: Schema.Number,
  elapsedMs: Schema.Number,
  at: Schema.Number,
});

const ProbeFailure = Schema.TaggedStruct('ProbeFailure', {
  monitorId: Schema.String,
  url: Schema.String,
  reason: Schema.Literals(['timeout', 'network', 'http-status']),
  at: Schema.Number,
});

const MonitorPaused = Schema.TaggedStruct('MonitorPaused', {
  monitorId: Schema.String,
  at: Schema.Number,
});

const MonitorResumed = Schema.TaggedStruct('MonitorResumed', {
  monitorId: Schema.String,
  at: Schema.Number,
});

const MonitorEvent = Schema.Union([ProbeSuccess, ProbeFailure, MonitorPaused, MonitorResumed]);

type MonitorEvent = Schema.Schema.Type<typeof MonitorEvent>;

Schema.TaggedStruct(tag, fields) это короткая запись для Schema.Struct({ _tag: Schema.Literal(tag), ...fields }). На сырой стороне ты получаешь { _tag: 'ProbeSuccess', ... }, на богатой стороне это размеченное объединение, которое в одном match или в catchTag нормально сужается по _tag. Связь со следующим уроком (03-errors) прямая: точно так же построены Data.TaggedError.

Поле at это эпоха в миллисекундах. Без него лог событий лишается оси времени, а на нём в уроке 09 будет работать Stream.groupedWithin с агрегацией по окнам. Поле url оставляем рядом с monitorId ради удобного форматирования в терминале и в SSE: подписчик не должен делать O(N)-лукап в конфиге, чтобы напечатать строку.

Шаг 3 · Round-trip

const event: MonitorEvent = {
  _tag: 'ProbeSuccess',
  monitorId: 'github',
  url: 'https://github.com',
  status: 200,
  elapsedMs: 142,
  at: 1_700_000_000_000,
};

const wire = Schema.encodeSync(MonitorEvent)(event);
const back = Schema.decodeUnknownSync(MonitorEvent)(wire);
// back deep-equals event

Это и есть симметрия, которую гарантирует Schema. Тест decode(encode(x)) === x для round-trip и encode(decode(j)) === j для wire-trip пишется в одну строку.

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

  • OptionFromOptionalKey(S) мостит “ключ может отсутствовать” с богатым Option<X>.
  • Schema.TaggedStruct(tag, ...) плюс Schema.Union([...]) это декларативный discriminated union.
  • Round-trip тест должен проходить, это базовая гарантия Schema.

Раздел 3 · Двусторонние трансформации

Сцена

Сырой JSON знает про string и number. В коде хочется Date, Duration, branded MonitorId. Schema.decodeTo склеивает две схемы явной парой функций, которая ходит в обе стороны. Если пара симметрична (encode(decode(x)) === x), обе формы корректно конвертируются друг в друга, и Schema.encodeEffect использует ту же декларацию.

Идея словами

Точка входа одна на все случаи, Schema.decodeTo. Он берёт схему-назначение и пару геттеров, по одному на направление:

fromSchema.pipe(
  Schema.decodeTo(
    toSchema, // что на выходе у decode и на входе у encode
    {
      decode: SchemaGetter.transform((from) => to),
      encode: SchemaGetter.transform((to) => from),
    },
  ),
);

Когда обе стрелки чистые и не падают, всю пару заменяет один хелпер SchemaTransformation.transform({ decode, encode }). Когда decode может упасть (не любая строка валидный URL), в поле decode идёт SchemaGetter.transformOrFail, и функция возвращает Effect: Effect.succeed(value) либо Effect.fail(new SchemaIssue.InvalidValue({ message })).

Одна тонкость, на которой легко споткнуться. Твой decode обязан вернуть Encoded-сторону схемы-назначения, а не её Type. Если назначение это branded число с проверками, ты возвращаешь голый number: brand и проверки навесит сама схема-назначение, руками звать её конструктор не нужно.

Шаг 1 · Симметричная пара · trim строки

import { Schema, SchemaTransformation } from 'effect';

const TrimmedString = Schema.String.pipe(
  Schema.decodeTo(
    Schema.String,
    SchemaTransformation.transform({
      decode: (s) => s.trim(),
      encode: (s) => s, // на encode ничего не делаем, считаем что внутри уже trimmed
    }),
  ),
);

decode чистит входную строку, encode отдаёт как есть. Пара несимметричная (decode не обратимо), это окей для этой задачи: на запись trim не нужен. Флага strict из старого API больше нет, типы обеих стрелок проверяются всегда.

Шаг 2 · Падающий decode · '30s' в Duration

Тот же decodeTo, но в поле decode встаёт геттер, которому разрешено провалиться. Это рабочая лошадка парсинга строк в богатые значения:

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

const DurationFromString = Schema.String.pipe(
  Schema.decodeTo(Schema.DurationFromMillis, {
    // встроенная схема-назначение: миллисекунды -> Duration
    decode: SchemaGetter.transformOrFail((input: string) => {
      const match = /^(\d+)(ms|s|m|h)$/.exec(input);
      if (match === null) {
        return Effect.fail(
          new SchemaIssue.InvalidValue({
            message: 'ожидается формат "30s", "1m", "2h", "500ms"',
          }),
        );
      }
      const value = Number(match[1]);
      const unit = match[2];
      const multiplier = unit === 'h' ? 3_600_000 : unit === 'm' ? 60_000 : unit === 's' ? 1000 : 1;
      return Effect.succeed(value * multiplier);
    }),
    // обратное направление упасть не может, поэтому чистый transform
    encode: SchemaGetter.transform((millis: number) => {
      if (millis % 3_600_000 === 0) return `${millis / 3_600_000}h`;
      if (millis % 60_000 === 0) return `${millis / 60_000}m`;
      if (millis % 1000 === 0) return `${millis / 1000}s`;
      return `${millis}ms`;
    }),
  }),
);

type DurationFromStringType = Schema.Schema.Type<typeof DurationFromString>;
//   ^? Duration
type DurationFromStringEncoded = Schema.Schema.Encoded<typeof DurationFromString>;
//   ^? string

Что важно:

  • decode принимает сырое значение уже распарсенное from-схемой. До твоей функции дошла строка, не unknown, проверка “это вообще строка” уже сделана.
  • На провале возвращаешь Effect.fail(new SchemaIssue.InvalidValue({ message })). Отдельного модуля ParseResult в Schema больше нет, его роль поделили между обычным Effect (контейнер результата) и SchemaIssue (словарь видов проблем).
  • Направления описываются по отдельности, и это удобно: если упасть может только decode, encode остаётся чистым SchemaGetter.transform, и в типах видно, что обратный путь всегда успешен.

Шаг 3 · Round-trip

const ms = Schema.decodeUnknownSync(DurationFromString)('30s');
// ms: Duration(30 000)

const back = Schema.encodeSync(DurationFromString)(ms);
// back: '30s'

Туда и обратно. Тестировать связку decodeTo ты будешь именно так: пара decode/encode, плюс отдельные кейсы на невалидный вход ('30x', '', 42).

Шаг 4 · Где встроенные трансформы

Часть рутинных трансформов уже есть в Schema, и каждый раз писать свой не нужно:

СхемаEncoded -> Type
Schema.NumberFromStringstring -> number
Schema.DateFromStringstring (ISO-8601) -> Date
Schema.DateFromMillisnumber (epoch ms) -> Date
Schema.fromJsonString(SomeSchema)string -> Type<SomeSchema> (через JSON.parse плюс decode)
Schema.URLFromStringstring -> URL
Schema.BigIntFromStringstring -> bigint
Schema.DurationFromMillisnumber -> Duration

Когда нашёл встроенный, бери его и не плоди свой.

Имена тут читаются как формула “что получилось из чего”. Голое имя (Schema.URL, Schema.Date, Schema.Duration) означает схему без превращения: на обеих сторонах уже готовый URL, Date, Duration. Превращение из строки или числа всегда названо явно, через From: URLFromString, DateFromString, DurationFromMillis.

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

  • Schema.decodeTo плюс SchemaTransformation.transform для симметричной пары без падений.
  • Schema.decodeTo плюс SchemaGetter.transformOrFail для пары, у которой decode может упасть (SchemaIssue.InvalidValue).
  • Перед тем как писать свой переход, проверь встроенные: NumberFromString, DateFromString, fromJsonString, URLFromString.

Раздел 4 · Проверки через .check

Сцена

Schema.String принимает любую строку, включая пустую. Часто этого мало: тебе нужна не пустая строка, число от 1 до 60, массив длиной хотя бы 1. Проверка это рефайнмент: она не меняет тип на encoded-стороне, но ставит дополнительное условие, которое Schema прогонит при decode.

Идея словами

Все проверки навешиваются одним методом .check(...), и каждая проверка это значение с говорящим именем на is:

const Positive = Schema.Number.check(Schema.isGreaterThan(0));
const NonEmpty = Schema.String.check(Schema.isNonEmpty());
const ShortName = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(50));

.check(...) принимает сколько угодно проверок через запятую, отдельный pipe на каждую не нужен. Если готовой проверки нет, соберёшь свою через Schema.makeFilter:

const EvenNumber = Schema.Number.check(
  Schema.makeFilter((n) => (n % 2 === 0 ? undefined : `ожидается чётное число, пришло ${n}`), {
    identifier: 'EvenNumber',
  }),
);

Предикат в makeFilter возвращает undefined или true, если всё в порядке, и строку с сообщением, если нет. Есть и более богатые формы возврата: готовый SchemaIssue.Issue, пара { path, issue } для провала на вложенном поле, массив таких пар, чтобы отдать сразу несколько проблем. На стороне Type ты получишь тот же number, но снабжённый проверкой.

Шаг 1 · Встроенные

Самое полезное для базы:

КатегорияПримеры
строкиisMinLength, isMaxLength, isLengthBetween, isPattern, isNonEmpty, isTrimmed, isLowercased
числаisGreaterThan, isLessThan, isBetween, isInt, isMultipleOf, isFinite
массивыisMinLength, isMaxLength, isLengthBetween
датыisBetweenDate, isLessThanDate, isGreaterThanDate

Проверок positive, negative, nonNegative больше нет, они выражаются через сравнение с нулём: isGreaterThan(0) и isGreaterThanOrEqualTo(0). Складываются проверки в один вызов:

const IntervalMs = Schema.Number.check(
  Schema.isInt(),
  Schema.isBetween({ minimum: 1000, maximum: 24 * 3_600_000 }), // от 1 секунды до 24 часов
);

Обрати внимание на isBetween: границы передаются именованным объектом, а не двумя позиционными числами. Перепутать минимум с максимумом теперь негде.

Шаг 2 · Своя проверка

const HexColor = Schema.String.check(
  Schema.makeFilter(
    (s) => (/^#[0-9a-f]{6}$/i.test(s) ? undefined : `ожидается hex-цвет вида #aabbcc, пришло ${s}`),
    { identifier: 'HexColor', description: 'CSS hex-цвет' },
  ),
);

identifier важен: он попадает в отчёт об ошибке и в подпись к схеме (генераторы JSON Schema и произвольных значений опираются на эти аннотации).

Шаг 3 · Проверка не делает branded

Это частая ловушка. После проверки тип всё ещё string, его можно случайно подставить в позицию обычной строки. Если нужна номинальная строгость на типах (“Url это не любая string”), накручивай brand сверху:

const Url = Schema.String.check(Schema.isPattern(/^https?:\/\//)).pipe(Schema.brand('Url'));
type Url = Schema.Schema.Type<typeof Url>;
//   ^? string & Brand<'Url'>

Заметь порядок вызовов: проверки идут методом .check(...), а brand остался шагом pipe, поэтому он приезжает последним. К brand вернёмся в Разделе 9. Пока запомни: проверка без brand это “проверь и забудь”, проверка плюс brand это “проверь и пометь”.

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

  • .check(...) для рефайнментов без brand, сколько угодно проверок за один вызов.
  • Встроенные проверки (isMinLength, isBetween, isInt, …) знают, как себя описать в ошибках.
  • Schema.makeFilter(predicate, annotations) для своей проверки.
  • Проверка не делает значение номинальным, для этого нужен Schema.brand.

Раздел 5 · Композиция

Сцена

Большие схемы пишутся не одной простыней, а сборкой из маленьких. У Schema.Struct для этого есть метод mapFields: он берёт словарь полей схемы и отдаёт новый. Что именно делать с полями, говорят функции из модуля Struct, того самого, которым ты правишь обычные объекты.

Шаг 1 · fieldsAssign · добавить поля к структуре

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

const Monitor = Base.pipe(
  Schema.fieldsAssign({
    interval: Schema.String,
    expect: Schema.Struct({ status: Schema.Number }),
  }),
);

type Monitor = Schema.Schema.Type<typeof Monitor>;
//   ^? { id: string; url: string; interval: string; expect: { status: number } }

fieldsAssign сливает поля. Удобно, когда у двух сущностей общая база (например, id, createdAt, updatedAt). Под капотом это короткая запись для Base.mapFields(Struct.assign({ ... })).

Шаг 2 · срез полей через mapFields

import { Struct } from 'effect';

const MonitorId = Monitor.mapFields(Struct.pick(['id']));
type MonitorId = Schema.Schema.Type<typeof MonitorId>;
//   ^? { id: string }

const MonitorWithoutExpect = Monitor.mapFields(Struct.omit(['expect']));
type MonitorWithoutExpect = Schema.Schema.Type<typeof MonitorWithoutExpect>;
//   ^? { id: string; url: string; interval: string }

Отдельных схемных pick и omit больше нет, и это к лучшему: одна дверь mapFields открывает весь Struct. Через неё же делаются массовые правки полей, например mapFields(Struct.map(Schema.optional)) вместо старого partial. Полезно для CRUD-семейства схем: MonitorCreate это Struct.omit(['id']) от Monitor, MonitorUpdate это Struct.pick(['url', 'interval']), и так далее.

Шаг 3 · Цепочка трансформаций

const RawMonitor = Schema.Struct({
  id: Schema.String,
  url: Schema.String,
  interval: Schema.String,
});

const PreparedMonitor = Schema.Struct({
  id: Schema.String,
  url: Schema.String,
  interval: Schema.DurationFromMillis,
});

const StringIntervalToDuration = RawMonitor.pipe(
  Schema.decodeTo(
    PreparedMonitor,
    SchemaTransformation.transform({
      decode: (raw) => ({ ...raw, interval: parseInterval(raw.interval) }),
      encode: (prepared) => ({ ...prepared, interval: stringifyInterval(prepared.interval) }),
    }),
  ),
);

Когда трансформаций несколько (сначала разобрать строку как JSON, потом валидировать, потом обогатить вычисляемыми полями), они выстраиваются цепочкой тем же decodeTo:

const Pipeline = Schema.fromJsonString(RawMonitor).pipe(
  //             string -> RawMonitor
  Schema.decodeTo(PreparedMonitor, intervalTransformation),
  //             RawMonitor -> PreparedMonitor
);

Отдельного compose в v4 нет, его работу делает тот же decodeTo: сцепить две схемы и склеить две трансформации это одна и та же операция. Encode пройдёт в обратную сторону автоматически: PreparedMonitor -> RawMonitor -> string. Это и есть ценность одной декларации: расширение пайплайна не требует двух копий.

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

  • fieldsAssign для добавления полей, mapFields плюс Struct.pick и Struct.omit для среза.
  • decodeTo и для одного перехода, и для последовательности. Encode идёт в обратном порядке сам.
  • CRUD-семейства (Create, Update, Patch) собирай через mapFields от базовой схемы, не пиши руками.

Раздел 6 · Проекции: toType и toEncoded

Сцена

Иногда тебе нужна только одна сторона. Например, ты пишешь функцию format(monitor: Monitor): string, ей всё равно, как Monitor декодится из JSON. Или наоборот: тестируешь сериализацию, и тебе нужен только wire-формат, без branded и Date. Schema умеет проецировать схему на одну сторону.

Идея словами

const MonitorTypeOnly = Schema.toType(Monitor);
//      ^? Codec<MonitorType, MonitorType>, оба параметра одинаковые

const MonitorEncodedOnly = Schema.toEncoded(Monitor);
//      ^? Codec<MonitorEncoded, MonitorEncoded>

toType(s) отбрасывает encoded-сторону: получается схема, которая работает с уже декодированными значениями, без проверок-конверсий. Полезно, когда тестируешь логику над уже валидированной формой.

toEncoded(s) отбрасывает type-сторону: получается схема голого wire-формата. Полезно для генерации wire-моков, OpenAPI-схем, тестов на сериализацию.

Шаг 1 · toType для тестов

const monitor: Monitor = { id: 'a', url: 'https://x', interval: 30_000 };
const ok = Schema.decodeUnknownSync(Schema.toType(Monitor))(monitor);
// ok: Monitor (тот же объект, без интервал-парсинга)

В тесте не приходится кормить '30s' и заставлять Schema гонять переход из строки. Ты сразу даёшь готовый объект, schema только проверяет форму. Кстати, это же и замена исчезнувшему семейству validate*: “проверить уже готовое значение” в v4 записывается как decode по toType-проекции.

Шаг 2 · toEncoded для wire-моков

const wireMonitor = Schema.decodeUnknownSync(Schema.toEncoded(Monitor))({
  id: 'a',
  url: 'https://x',
  interval: '30s',
});
// wireMonitor: { id: string; url: string; interval: string }

Удобно в тестах роутера: ты не хочешь пускать запрос через все трансформы, тебе нужна форма “как пришло из браузера”. toEncoded это делает.

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

  • Schema.toType(s) оставляет только богатую сторону. Полезно для бизнес-тестов и вместо старого validate*.
  • Schema.toEncoded(s) оставляет только wire-форму. Полезно для тестов сериализации.
  • Этим двум проекциям все аннотации (identifier, description) переезжают вместе, ничего не теряется.

Раздел 7 · Прощающие схемы: optionalKey, дефолты, NullOr

Сцена

Конфиг редко обязывает пользователя заполнить все поля. Хочется так: пишешь { "monitors": [...] } без секции defaults, и в коде получаешь полностью заполненный PulseConfig с дефолтами. Это и есть прощающая схема. Schema даёт несколько кирпичей, и из них собирается общий шаблон.

Шаг 1 · Дефолт на стороне декода

import { Effect, Schema } from 'effect';

const Defaults = Schema.Struct({
  interval: Schema.Number.pipe(Schema.withDecodingDefaultType(Effect.succeed(30_000))),
  timeout: Schema.Number.pipe(Schema.withDecodingDefaultType(Effect.succeed(5000))),
  retries: Schema.Number.pipe(Schema.withDecodingDefaultType(Effect.succeed(0))),
});

type Defaults = Schema.Schema.Type<typeof Defaults>;
//   ^? { interval: number; timeout: number; retries: number }, всё обязательное

type DefaultsEncoded = Schema.Schema.Encoded<typeof Defaults>;
//   ^? { interval?: number; timeout?: number; retries?: number }, всё опциональное

Заметь асимметрию: на стороне Encoded поля опциональны (можешь не присылать), на стороне Type они обязательные (после декода значение всегда есть). Это и есть “прощение”: ты прощаешь автору JSON, он даёт меньше, ты внутри уже не проверяешь “а вдруг null”.

Универсального optionalWith со словарём опций больше нет, вместо него набор точных инструментов, и имя каждого говорит, что именно он делает:

Что нужноЧем делается
ключа может не бытьSchema.optionalKey(S)
ключ может быть равен undefinedSchema.optional(S)
ключа может не быть, подставить дефолтS.pipe(Schema.withDecodingDefaultTypeKey(...))
ключ может быть undefined, подставить дефолтS.pipe(Schema.withDecodingDefaultType(...))

Дефолт теперь передаётся Effect-ом, а не функцией-заглушкой: Effect.succeed(30_000). Выигрыш в том, что дефолт может быть не константой, а вычислением с зависимостями, например текущим временем из Clock.

Шаг 2 · Schema.NullOr и Schema.UndefinedOr

const Description = Schema.NullOr(Schema.String);
type Description = Schema.Schema.Type<typeof Description>;
//   ^? string | null

NullOr принимает null или значение, оставляя null в типе. Если ты хочешь конвертировать null в Option.none(), используй OptionFromNullOr:

const MaybeDescription = Schema.OptionFromNullOr(Schema.String);
type MaybeDescription = Schema.Schema.Type<typeof MaybeDescription>;
//   ^? Option<string>, в типе уже не вспоминаем про null

Это снова про “одну схему, два мира”: снаружи null-friendly JSON, внутри строгий Option.

Шаг 3 · withConstructorDefault · значение в самом конструкторе

Ещё один уровень дефолтов: значение, которое подставляется при программной конструкции, без декода:

const Tag = Schema.String.pipe(
  Schema.optionalKey,
  Schema.withConstructorDefault(Effect.succeed('general')),
);

const Note = Schema.Struct({
  text: Schema.String,
  tag: Tag,
});

const note = Note.make({ text: 'привет' });
// note: { text: 'привет', tag: 'general' }

Note.make(input) это конструктор, доступный на любой Schema.Struct. С withConstructorDefault поле можно пропустить в make, и оно подставится. Дефолт тут тоже Effect, как и в withDecodingDefaultType, но применяется он только в make, при декоде и энкоде не срабатывает. Удобно, когда есть таблица в БД с DEFAULT 'general' и хочется такое же поведение в коде.

Шаг 4 · Прощающий шаблон в общем виде

Ты накатываешь несколько слоёв:

  1. На уровне Schema, optionalKey или NullOr для тех полей, которые отсутствуют в JSON.
  2. На уровне Schema, withConstructorDefault для тех полей, которые отсутствуют в коде.
  3. На уровне типа, после декода поля обязательные, прощение уже ушло вниз и не торчит наверху.

Этот шаблон работает на конфиги, входные DTO, формы. Когда видишь “я хочу сделать форму, в которой половина полей опциональны, а на типах хочется заполненный объект”, это он.

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

  • withDecodingDefaultType(Effect.succeed(...)) для дефолтов на стороне декода.
  • OptionFromNullOr для null -> Option.
  • withConstructorDefault(Effect.succeed(...)) для дефолтов на стороне make.
  • После декода тип всегда полный, прощение спрятано в схеме, наверх не протекает.

Раздел 8 · Эффектные трансформации

Сцена

SchemaGetter.transformOrFail умеет не только синхронно фейлиться. Он умеет тянуть зависимости и делать асинхронную работу. Редкий, но рабочий приём: парсинг, который требует Clock, кэша, БД, сервиса.

Идея словами

У схемы есть свойство DecodingServices, и до этого момента у нас везде стоял never, потому что чистые переходы ни от чего не зависят. Как только твой геттер возвращает Effect<X, SchemaIssue.Issue, SomeService>, в DecodingServices появляется SomeService, и Schema.decodeEffect(s)(value) тоже становится Effect<A, SchemaError, SomeService>. Ты обязан подложить сервис через Effect.provide перед запуском. Симметрично для encode есть EncodingServices, направления учитываются по отдельности.

Шаг 1 · Парсинг с Clock-зависимостью

Допустим, в JSON приходит relative time '5m ago', и его нужно превратить в абсолютный Date. Для этого нужно знать “сейчас”, а “сейчас” в Effect это сервис Clock:

import { Clock, Effect, Schema, SchemaGetter, SchemaIssue } from 'effect';

const RelativeDateFromString = Schema.String.pipe(
  Schema.decodeTo(Schema.Date, {
    decode: SchemaGetter.transformOrFail((input: string) =>
      Effect.gen(function* () {
        const match = /^(\d+)(s|m|h)\s+ago$/.exec(input);
        if (match === null) {
          return yield* Effect.fail(
            new SchemaIssue.InvalidValue({
              message: 'ожидается формат "5m ago", "30s ago", "2h ago"',
            }),
          );
        }
        const value = Number(match[1]);
        const unit = match[2];
        const ms = unit === 'h' ? value * 3_600_000 : unit === 'm' ? value * 60_000 : value * 1000;
        const now = yield* Clock.currentTimeMillis;
        return new Date(now - ms);
      }),
    ),
    encode: SchemaGetter.transformOrFail((date: Date) =>
      Effect.gen(function* () {
        const now = yield* Clock.currentTimeMillis;
        const ago = now - date.getTime();
        if (ago % 3_600_000 === 0) return `${ago / 3_600_000}h ago`;
        if (ago % 60_000 === 0) return `${ago / 60_000}m ago`;
        return `${ago / 1000}s ago`;
      }),
    ),
  }),
);

type R = Schema.Schema.DecodingServices<typeof RelativeDateFromString>;
//   ^? Clock

Схема-назначение тут Schema.Date, то есть “на обеих сторонах уже готовый Date”, превращение делает наш геттер. И DecodingServices подсветил, что без Clock ничего не запустится. В тестах подложишь TestClock (про него урок 13), в проде это встроенный Clock, который Effect даёт автоматом.

Шаг 2 · Когда не надо

Эффектные трансформации легко превращают парсинг в “кусок бизнес-логики” с побочными эффектами. Удобство и опасность тут идут поровну. Правила, которым стоит следовать:

  • Не клади в трансформ запросы в сеть и в БД. Парсинг обязан быть детерминированным и быстрым. Походы в БД делай отдельным шагом после декода (flatMap).
  • Допустимо: чтение Clock, обращение к локальному кэшу, маленькая лукап-таблица из Layer.
  • Полезно: эффектный геттер в связке с резолвером DNS из урока 10, когда ты хочешь, чтобы параллельные decode-вызовы автоматически батчили DNS-резолвинг.

Откуда правило: на одном из проектов парсер конфига сходил в БД “проверить, что monitor.id ещё не занят”. Локально всё летало. На стейдже под нагрузкой decode массива из 200 мониторов превратился в 200 последовательных запросов, и старт сервиса вырос с двух секунд до сорока. Поправили в один ход: проверку id вытащили из схемы в отдельный шаг после декода, и парсинг снова стал детерминированным. На уроке 13 будет чек-лист “когда трансформ это плохо”, сейчас держи в голове правило выше.

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

  • DecodingServices и EncodingServices у схемы тащат зависимости перехода, по одному набору на направление.
  • SchemaGetter.transformOrFail принимает Effect, можно использовать сервисы.
  • Не превращай парсинг в бизнес-логику. Сеть и БД, отдельным шагом, не внутри схемы.

Раздел 9 · Opaque types и nominal typing

Сцена

TypeScript структурный: string это string, никакой тип-семантики “это именно ID юзера” в нём нет. Ты можешь случайно передать MonitorId в позицию OrderId, компилятор пропустит. На уроке 04-ts/04 мы делали nominal typing руками через & Brand<'X'>. Schema даёт встроенный механизм через Schema.brand, плюс обещает консистентность с runtime-проверкой.

Шаг 1 · Schema.brand

const MonitorId = Schema.String.check(
  Schema.isPattern(/^[a-z][a-z0-9-]*$/, { identifier: 'MonitorId' }),
).pipe(Schema.brand('MonitorId'));

type MonitorId = Schema.Schema.Type<typeof MonitorId>;
//   ^? string & Brand<'MonitorId'>

const id = Schema.decodeUnknownSync(MonitorId)('github-www');
// id: MonitorId

declare function getEvents(id: MonitorId): void;

const stringMonitorId: string = 'github-www';
getEvents(stringMonitorId);
//        ^^^^^^^^^^^^^^^^ Type 'string' is not assignable to type 'MonitorId'

Schema.brand(name) это шаг pipe, который добавляет к Type-стороне фантомный brand. На рантайме это ничего не стоит: после JSON ты имеешь обычную строку, brand живёт только в типах.

Шаг 2 · Smart constructor

const id = MonitorId.make('github-www');
// id: MonitorId

const broken = MonitorId.make('GitHub-WWW');
// бросит на рантайме: строка не подошла под шаблон

make это smart-constructor, который запускает валидацию синхронно. Полезен в коде, где ты сам конструируешь branded-значения (например, в фабриках событий). Если строка точно не пройдёт, упадёт на месте, без decode через Effect.

Шаг 3 · Opaque types через Schema.Class

Schema.brand хорош для строк и чисел. Когда ID это структура (например, составной ключ { tenant: string; monitor: string }), удобнее Schema.Class:

class MonitorRef extends Schema.Class<MonitorRef>('MonitorRef')({
  tenant: Schema.String,
  monitor: Schema.String,
}) {
  toString() {
    return `${this.tenant}/${this.monitor}`;
  }
}

const ref = new MonitorRef({ tenant: 'acme', monitor: 'web' });
// ref instanceof MonitorRef === true
// String(ref) === 'acme/web'

Schema.Class это полноценный класс, у него есть конструктор с валидацией, методы, instanceof, и при этом он схема: Schema.decodeUnknownEffect(MonitorRef) работает.

Шаг 4 · Когда что брать

ХочешьБери
сильный тип над string/number без накладных расходовSchema.brand
сильный тип над структурой, плюс методы, плюс instanceofSchema.Class
равенство и hashSchema.Class, сравнение через Equal.equals работает по структуре само
union тэг-структур (см. Раздел 2)Schema.TaggedStruct плюс Schema.Union

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

  • Schema.brand это nominal typing на типах плюс синхронный smart-constructor .make.
  • Brand стирается в JS, на рантайме это голый string или number. Никаких накладных расходов.
  • Если нужны методы, instanceof, наследование, переходи на Schema.Class.

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

Четыре новых файла в pulse-<nick>: src/config.ts, src/events.ts, pulse.config.json, плюс src/errors.ts обрастает вторым классом. src/index.ts обновляется на чтение конфига вместо process.argv.

src/errors.ts

import { Data } from 'effect';

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

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

ConfigParseError встаёт рядом с NetworkError. Тот же Data.TaggedError, тот же _tag. В уроке 03 · Tagged-ошибки здесь поселятся ещё четыре класса (HttpStatusError, BodyContractError, TimeoutError, StorageError), и весь E-канал Pulse будет жить в одном файле.

src/config.ts

import { readFile } from 'node:fs/promises';

import { Effect, Match, Schema, SchemaGetter, SchemaIssue } from 'effect';

import { ConfigParseError } from './errors.ts';

export const MonitorId = Schema.String.check(
  Schema.isPattern(/^[a-z][a-z0-9-]*$/, { identifier: 'MonitorId' }),
).pipe(Schema.brand('MonitorId'));
export type MonitorId = Schema.Schema.Type<typeof MonitorId>;

export const Url = Schema.String.check(
  Schema.isPattern(/^https?:\/\//, { identifier: 'Url' }),
).pipe(Schema.brand('Url'));
export type Url = Schema.Schema.Type<typeof Url>;

const IntervalMs = Schema.Number.check(
  Schema.isInt(),
  Schema.isBetween({ minimum: 100, maximum: 24 * 3_600_000 }),
).pipe(Schema.brand('Interval'));

type IntervalUnit = 'ms' | 's' | 'm' | 'h';

const unitToMs = Match.type<IntervalUnit>().pipe(
  Match.when('h', () => 3_600_000),
  Match.when('m', () => 60_000),
  Match.when('s', () => 1000),
  Match.when('ms', () => 1),
  Match.exhaustive,
);

export const Interval = Schema.String.pipe(
  Schema.decodeTo(IntervalMs, {
    decode: SchemaGetter.transformOrFail((input: string) => {
      const match = /^(\d+)(ms|s|m|h)$/.exec(input);
      if (match === null) {
        return Effect.fail(
          new SchemaIssue.InvalidValue({
            message: 'ожидается формат "30s", "1m", "2h", "500ms"',
          }),
        );
      }
      const value = Number(match[1]);
      const unit = match[2] as IntervalUnit;
      return Effect.succeed(value * unitToMs(unit));
    }),
    encode: SchemaGetter.transform((millis: number) => {
      if (millis % 3_600_000 === 0) return `${millis / 3_600_000}h`;
      if (millis % 60_000 === 0) return `${millis / 60_000}m`;
      if (millis % 1000 === 0) return `${millis / 1000}s`;
      return `${millis}ms`;
    }),
  }),
);
export type Interval = Schema.Schema.Type<typeof Interval>;

export const Expect = Schema.Struct({
  status: Schema.Number.pipe(Schema.withDecodingDefaultType(Effect.succeed(200))),
});
export type Expect = Schema.Schema.Type<typeof Expect>;

export const Monitor = Schema.Struct({
  id: MonitorId,
  url: Url,
  interval: Interval,
  expect: Expect.pipe(Schema.withDecodingDefaultType(Effect.succeed({ status: 200 }))),
});
export type Monitor = Schema.Schema.Type<typeof Monitor>;

export const MonitorDefaults = Schema.Struct({
  interval: Schema.Number.pipe(Schema.withDecodingDefaultType(Effect.succeed(30_000))),
  timeout: Schema.Number.pipe(Schema.withDecodingDefaultType(Effect.succeed(5000))),
  retries: Schema.Number.pipe(Schema.withDecodingDefaultType(Effect.succeed(0))),
});
export type MonitorDefaults = Schema.Schema.Type<typeof MonitorDefaults>;

const monitorDefaults: MonitorDefaults = { interval: 30_000, timeout: 5000, retries: 0 };

export const PulseConfig = Schema.Struct({
  monitors: Schema.Array(Monitor).check(Schema.isMinLength(1)),
  defaults: MonitorDefaults.pipe(Schema.withDecodingDefaultType(Effect.succeed(monitorDefaults))),
});
export type PulseConfig = Schema.Schema.Type<typeof PulseConfig>;

export const decodeFromFile = (path: string): Effect.Effect<PulseConfig, ConfigParseError> =>
  Effect.gen(function* () {
    const text = yield* Effect.tryPromise({
      try: () => readFile(path, 'utf8'),
      catch: (cause) => new ConfigParseError({ path, cause }),
    });
    const json = yield* Effect.try({
      try: () => JSON.parse(text) as unknown,
      catch: (cause) => new ConfigParseError({ path, cause }),
    });
    return yield* Schema.decodeUnknownEffect(PulseConfig)(json).pipe(
      Effect.mapError((cause) => new ConfigParseError({ path, cause })),
    );
  });

Прочитай сигнатуру decodeFromFile вслух. “Берёт path, возвращает Effect, который при успехе отдаст PulseConfig, при ожидаемой ошибке отдаст ConfigParseError, и для запуска ему ничего внешнего не нужно”. Это и есть тот самый Effect<A, E, R>, ради которого мы прошли урок 01 · Effect intro.

IntervalMs снабжён проверками Schema.isInt() и Schema.isBetween({ minimum: 100, maximum: 24 * 3_600_000 }). После перехода строка '30s' превращается в 30_000, и обе проверки отрабатывают на богатой стороне: дробные интервалы и интервалы вне разумного окна (меньше 100 ms или больше суток) отлетят с понятной ошибкой ещё до первого fetch.

Заметь, что decode-геттер возвращает голое число, а не IntervalMs.make(...). Это та самая тонкость из Раздела 3: геттер отдаёт Encoded-сторону схемы-назначения, а brand и проверки навешивает уже сама IntervalMs.

PulseConfig.defaults сделан прощающим: пустой объект на этом ключе подставит полный набор MonitorDefaults, а отсутствие ключа целиком выставит дефолты из withDecodingDefaultType. Снаружи разрешено молчать, внутри тип уже полный. Поведение MonitorDefaults-строки как самостоятельной схемы разбирали в Разделе 7.

src/events.ts

import { Schema } from 'effect';

import { MonitorId } from './config.ts';

export const ProbeSuccess = Schema.TaggedStruct('ProbeSuccess', {
  monitorId: MonitorId,
  url: Schema.String,
  status: Schema.Number,
  elapsedMs: Schema.Number,
  at: Schema.Number,
});
export type ProbeSuccess = Schema.Schema.Type<typeof ProbeSuccess>;

export const ProbeFailure = Schema.TaggedStruct('ProbeFailure', {
  monitorId: MonitorId,
  url: Schema.String,
  reason: Schema.Literals(['timeout', 'network', 'http-status']),
  at: Schema.Number,
});
export type ProbeFailure = Schema.Schema.Type<typeof ProbeFailure>;

export const MonitorPaused = Schema.TaggedStruct('MonitorPaused', {
  monitorId: MonitorId,
  at: Schema.Number,
});
export type MonitorPaused = Schema.Schema.Type<typeof MonitorPaused>;

export const MonitorResumed = Schema.TaggedStruct('MonitorResumed', {
  monitorId: MonitorId,
  at: Schema.Number,
});
export type MonitorResumed = Schema.Schema.Type<typeof MonitorResumed>;

export const MonitorEvent = Schema.Union([
  ProbeSuccess,
  ProbeFailure,
  MonitorPaused,
  MonitorResumed,
]);
export type MonitorEvent = Schema.Schema.Type<typeof MonitorEvent>;

MonitorEvent это весь словарь событий Pulse. Два ивента про проверки (ProbeSuccess, ProbeFailure), два про жизненный цикл монитора (MonitorPaused, MonitorResumed). На уроке 04 они полетят в Storage, на уроке 07 в MonitorEvents, на уроке 09 в Stream с агрегацией по окнам.

pulse.config.json

{
  "monitors": [
    {
      "id": "github-www",
      "url": "https://github.com",
      "interval": "30s"
    },
    {
      "id": "self-api",
      "url": "https://api.self.local/health",
      "interval": "1m",
      "expect": { "status": 200 }
    }
  ]
}

src/index.ts

import { Effect } from 'effect';
import { decodeFromFile } from './config.ts';
import { probe } from './probe.ts';

const program = Effect.gen(function* () {
  const config = yield* decodeFromFile('./pulse.config.json');
  for (const monitor of config.monitors) {
    const result = yield* probe(monitor.url);
    yield* Effect.sync(() => {
      console.log(`${monitor.id}: status ${result.status} in ${result.elapsedMs} ms`);
    });
  }
});

Effect.runPromise(program).catch((cause) => {
  console.error('pulse failed:', cause);
  process.exit(2);
});

Запусти pnpm start. Ты увидишь по одной строке на каждый монитор. Никакого расписания (придёт в уроке 11), никакого параллелизма (урок 06), но типизированный конфиг уже есть, и попытка положить в JSON { "interval": "30wat" } упадёт до первого fetch, с понятным сообщением о пути до сломанного поля.

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

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

Если по любому пункту “не уверен”, вернись к Разделу 3 и собери Interval руками с нуля. Это самый плотный кусок урока, на нём держатся следующие 11.

ДЗ

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

Дальше

Следующий урок · 03. Tagged-ошибки и матчинг. Там ConfigParseError становится частью семейства из шести tagged-ошибок Pulse, и появляются catchTag, matchEffect, граница с ts-pattern.

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

  • 04-ts · 04 Reliability and types, про parse-don’t-validate на zod. Тот же приём, другое API. После этого урока сравни сам, где Schema выгоднее, где zod проще.
  • 02-cs · 08 Системы эффектов, теоретическая база, чтобы понять, почему Effect и Schema это одно хозяйство, а не две независимые либы.