Schema: parse-don't-validate, branded типы, transform
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
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, brandednumber(миллисекунды) с биндингом из строки'30s'черезSchema.decodeTo.Expect, optional объект с дефолтом{ status: 200 }.MonitorEvent, размеченное объединение из четырёх веток (успешная проверка, провал проверки, остановка монитора, возобновление). Это словарь событий Pulse: каждая проверка адреса порождает ровно одну такую запись, дальше мы складываем её в локальный журнал и рассылаем подписчикам, чтобы из этого потока потом строить живую таблицу в терминале и SSE для браузера. Полностью разберём в уроке 03 · Tagged-ошибки вместе с семейством tagged-ошибок.
Одна команда Schema.decodeUnknownEffect(PulseConfig)(rawJson) либо вернёт строго типизированный конфиг, либо отдаст структурированную ошибку парсинга, на которую можно навести курсор и увидеть, где именно сломалось.
Карта урока · что заберёшь домой
К концу урока у Pulse появится типизированный конфиг с понятными ошибками парсинга, а ты будешь читать любую чужую схему за минуту. Маршрут такой:
- Decode и
Schema.Struct. Базовые типы иdecodeUnknownEffect,EncodedvsTypeна простом случае. - Encode на практике. Почему
OptionиUnionв нём интереснее всего,TaggedStructдля размеченных объединений. - Двусторонние трансформации.
Schema.decodeToвместе сSchemaTransformation.transformиSchemaGetter.transformOrFail, парсим'30s'вDurationи обратно. - Проверки через
.check(...). Разница между проверкой иSchema.brand. - Композиция.
fieldsAssign,mapFields,decodeTo, большие схемы из маленьких. - Проекции.
toTypeиtoEncoded, когда нужна только одна сторона. - Прощающие схемы.
optionalKey,withDecodingDefaultType,NullOr,withConstructorDefault. - Эффектные трансформации. Геттер, который умеет тянуть зависимости: декод, который требует
ClockилиHttpClient. - Opaque types.
Schema.brandиSchema.Class, nominal typing без своего класса и со своим классом. - 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.String | string |
Schema.Number | number |
Schema.Boolean | boolean |
Schema.Null | null |
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.NumberFromString | string -> number |
Schema.DateFromString | string (ISO-8601) -> Date |
Schema.DateFromMillis | number (epoch ms) -> Date |
Schema.fromJsonString(SomeSchema) | string -> Type<SomeSchema> (через JSON.parse плюс decode) |
Schema.URLFromString | string -> URL |
Schema.BigIntFromString | string -> bigint |
Schema.DurationFromMillis | number -> 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) |
ключ может быть равен undefined | Schema.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 · Прощающий шаблон в общем виде
Ты накатываешь несколько слоёв:
- На уровне Schema,
optionalKeyилиNullOrдля тех полей, которые отсутствуют в JSON. - На уровне Schema,
withConstructorDefaultдля тех полей, которые отсутствуют в коде. - На уровне типа, после декода поля обязательные, прощение уже ушло вниз и не торчит наверху.
Этот шаблон работает на конфиги, входные 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 |
сильный тип над структурой, плюс методы, плюс instanceof | Schema.Class |
| равенство и hash | Schema.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 это одно хозяйство, а не две независимые либы.