До сих пор разбор отдавал простые объекты, а вопросы к кадру жили в отдельных функциях. Это нормально, пока вопросов два. Когда «срочный ли кадр» спрашивают в десяти местах, хочется, чтобы ответ жил рядом с кадром. В TypeScript для этого есть классы с геттерами, и Schema умеет делать класс из схемы.
Конверт
export class Envelope extends Schema.Class<Envelope>('Envelope')(StampedFrame.fields) {
get urgent(): boolean {
return this.priority === 'urgent';
}
get hops(): number {
return this.relay === undefined ? 1 : 2;
}
}
Schema.Class<Envelope>('Envelope')(поля) строит одновременно класс и схему. Первый вызов принимает сам класс параметром типа, чтобы схема знала, экземпляры чего она отдаёт, и имя для сообщений; второй принимает поля. Поля взяты у StampedFrame из прошлого урока, с печатями и временем. В теле класса обычные геттеры.
const envelope = Schema.decodeUnknownSync(Envelope)({
id: 7,
route: ['T-9', 'T-1'],
priority: 'urgent',
headers: {},
message: { kind: 'text', body: 'гроза' },
relay: 'T-5',
sentAt: '2026-11-01T06:00:00Z',
});
console.log(envelope instanceof Envelope, envelope.urgent, envelope.hops);
console.log(Schema.encodeSync(Envelope)(envelope));
true true 2
{
id: 7,
route: [ 'T-9', 'T-1' ],
priority: 'urgent',
headers: {},
message: { kind: 'text', body: 'гроза' },
relay: 'T-5',
sentAt: '2026-11-01T06:00:00.000Z'
}
Разбор отдаёт экземпляр Envelope: у него есть геттеры, и instanceof его узнаёт. Запись отдаёт простой объект для провода, без класса, с временем строкой. Стороны те же, что у любой схемы: в башне конверт, на проводе JSON.
Главное отличие от обычного класса в конструкторе. new Envelope({...}) проверяет поля так же, как разбор:
new Envelope({ ...envelope, route: [envelope.route[0], envelope.route[0]] });
Error: Schema validation failed
Маршрут из одной башни дважды не пройдёт ни через разбор, ни через new. Конструктор бросает, потому что его зовёт код самой Академии: собрать неправильный конверт в башне это ошибка программиста, а не беда входа. Для входа с провода по-прежнему разбор, у него ошибка в типе и подробный путь.
Бумаги с тегом
У Академии свои бумаги, и им нужны теги, как сообщениям в уроке 07. Schema.TaggedClass добавляет поле _tag:
export class Accepted extends Schema.TaggedClass<Accepted>()('Accepted', {
id: FrameId,
}) {}
console.log(new Accepted({ id: FrameId.make(7) }));
Accepted { _tag: 'Accepted', id: 7 }
Квитанция о приёме несёт номер с печатью: FrameId из урока 78. Простое число в конструктор не передать, компилятор потребует бренд.
Отказ это ошибка, и для ошибок есть свой класс, Schema.TaggedError. Ты встречал его в уроке 39: призрак PhantomAck в башне описан именно так, потому что уходит в отчёт.
export class Rejected extends Schema.TaggedError<Rejected>()('Rejected', {
at: Schema.Number,
reasons: Schema.Array(Schema.String),
}) {}
const intake = (at: number): Effect.Effect<Accepted, Rejected> =>
at === 2
? Effect.fail(new Rejected({ at, reasons: ['priority: нет такого слова'] }))
: Effect.succeed(new Accepted({ id: FrameId.make(at) }));
const answer = intake(2).pipe(
Effect.catchTag('Rejected', (rejected) => Effect.succeed(`отказ ${rejected.at}: ${rejected.reasons.join('; ')}`)),
);
console.log(Effect.runSync(answer));
console.log(Schema.encodeSync(Rejected)(new Rejected({ at: 2, reasons: ['priority: нет такого слова'] })));
отказ 2: priority: нет такого слова
{ _tag: 'Rejected', at: 2, reasons: [ 'priority: нет такого слова' ] }
Rejected умеет две вещи сразу. Как ошибка дела она ловится catchTag по тегу, как любая ошибка с тегом. Как схема она записывается в простой объект: отказ уходит гильдии по проводу, и гильдия разберёт его своей копией договора. Data.TaggedError из EF4 первое умеет, второе нет.
Бланк из бланка
Черновик кадра это кадр без номера: номер даёт башня, когда кадр уходит. Переписывать шесть полей ради одного выкинутого незачем:
import { Schema, Struct } from 'effect';
export const Draft = StampedFrame.mapFields(Struct.omit(['id']));
export type Draft = typeof Draft.Type;
console.log(Object.keys(Draft.fields));
[ 'route', 'priority', 'headers', 'message', 'relay', 'sentAt' ]
mapFields берёт функцию от полей схемы к новым полям и строит новую схему. Struct.omit(['id']) из модуля Struct такая функция: убирает ключ. Есть и Struct.pick, и обычный разворот { ...StampedFrame.fields, ... }, которым ты собирал Frame в уроке 78. mapFields удобнее, когда поле надо убрать: разворот только добавляет и подменяет.
Где тут дыра
class LooseEnvelope {
constructor(
readonly route: readonly [string, string],
readonly priority: string,
) {}
get urgent(): boolean {
return this.priority === 'urgent';
}
}
const odd = new LooseEnvelope(['T-1', 'T-1'], 'срочно');
console.log(odd, odd.urgent);
LooseEnvelope { route: [ 'T-1', 'T-1' ], priority: 'срочно' } false
Конверт из башни самой себе, со срочностью словом, которого нет в договоре, собрался без звука. И геттер честно ответил false: кадр «срочно» не срочный, потому что 'срочно' !== 'urgent'. Конструктор, который просто раскладывает аргументы по полям, это дверь в обход договора: разбор на входе проверяет, а new в башне нет. Класс из схемы закрывает эту дверь: проверяет и разбор, и конструктор, по одному бланку.
Магистраль, этап 5
Кадр становится конвертом с вопросами urgent и hops, а у Академии появляются свои бумаги: квитанция Accepted, отказ Rejected и черновик Draft без номера. В следующем уроке на эти бумаги ляжет приём ночной пачки кадров.
Практика
Дальше
Утром в мастерскую приносят письмо от гильдии ткачей. Они отправили Академии пачку кадров, и на всё получили один ответ: invalid input. Казимир держит письмо двумя пальцами.
Ошибки разбора словами для людей ждут в следующем уроке.