Раздел 46 · Effect: Тихая Академия

Классы схем

middle~20 мин

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

До сих пор разбор отдавал простые объекты, а вопросы к кадру жили в отдельных функциях. Это нормально, пока вопросов два. Когда «срочный ли кадр» спрашивают в десяти местах, хочется, чтобы ответ жил рядом с кадром. В 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. Казимир держит письмо двумя пальцами.

Ошибки разбора словами для людей ждут в следующем уроке.