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

Две стороны схемы

middle-senior~20 мин

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

До сих пор провод и башня говорили на одном языке. Число из JSON было числом в башне, строка строкой; бренд из прошлого урока добавил только метку в типе. Время так не переносится. JSON не знает типа «время», и Магистраль пишет его строкой "2026-11-01T06:00:00Z". Башне нужно значение, которое можно сравнить, сдвинуть на минуту и показать в часовом поясе: DateTime из урока 74.

Две стороны

У каждой схемы два типа, и ты их уже видел:

  • typeof S.Type: значение в башне, после разбора;
  • typeof S.Encoded: то, что лежит на проводе, до разбора.

У Schema.String и Schema.Number стороны совпадают. У схемы времени нет:

import { DateTime, Schema } from 'effect';

const Stamp = Schema.Struct({ id: Schema.Number, sentAt: Schema.DateTimeUtcFromString });

const stamp = Schema.decodeUnknownSync(Stamp)({ id: 7, sentAt: '2026-11-01T06:00:00Z' });
console.log(stamp, DateTime.formatIso(stamp.sentAt));
console.log(Schema.encodeSync(Stamp)(stamp));
{ id: 7, sentAt: DateTime.Utc(2026-11-01T06:00:00.000Z) } 2026-11-01T06:00:00.000Z
{ id: 7, sentAt: '2026-11-01T06:00:00.000Z' }

Разбор идёт с провода в башню: строка стала DateTime.Utc. Запись идёт обратно: DateTime.Utc снова строка. decodeUnknownSync и encodeSync это те же разбор и запись, что в уроке 76, только они бросают, а не отдают квитанцию; в примерах так короче, в башне бери версии с Effect.

Схему, у которой стороны разные, называют кодеком: она и кодирует, и раскодирует.

Перевод с Ук

Ты собираешь ответ для провода и кладёшь в него разобранную отметку:

import { Schema } from 'effect';

const Stamp = Schema.Struct({ id: Schema.Number, sentAt: Schema.DateTimeUtcFromString });

declare const stamp: typeof Stamp.Type;

export const wire: typeof Stamp.Encoded = stamp;
frame.ts(7,14): error TS2322: Type '{ readonly id: number; readonly sentAt: Utc; }' is not assignable to type '{ readonly id: number; readonly sentAt: string; }'.
  Types of property 'sentAt' are incompatible.
    Type 'Utc' is not assignable to type 'string'.

Снизу: Utc не строка. Выше: это поле sentAt. Ещё выше видны обе стороны целиком: слева Type с sentAt: Utc, справа Encoded с sentAt: string. Компилятор держит их раздельно, и это главная польза: значение из башни нельзя по ошибке отправить на провод без записи. Чинится не приведением, а записью: Schema.encodeSync(Stamp)(stamp) переведёт Utc в строку.

Строка JSON

Магистраль присылает не объект, а строку JSON. Её разбор тоже перевод: из строки в объект. В Schema он есть готовый:

const Wire = Schema.fromJsonString(Stamp);

console.log(Schema.decodeUnknownSync(Wire)('{"id":7,"sentAt":"2026-11-01T06:00:00Z"}'));
console.log(Schema.encodeSync(Wire)(stamp));
{ id: 7, sentAt: DateTime.Utc(2026-11-01T06:00:00.000Z) }
{"id":7,"sentAt":"2026-11-01T06:00:00.000Z"}

fromJsonString(Stamp) это два перевода подряд: строка в JSON-объект, объект в Stamp. На проводе одна строка, в башне DateTime. И ошибки приходят от обоих шагов, каждая со своим местом:

Expected a valid JSON string

Expected a valid UTC DateTime string
  at ["sentAt"]

Первая на обрезанной строке {"id": 7, вторая на "sentAt":"утром". JSON.parse руками больше не нужен, и его SyntaxError не вылетит посреди башни.

Свой перевод

Готовых переводов много: числа из строк, время из строк и миллисекунд, URL, Duration. Но у башни есть формат, которого Schema не знает: её собственный провод. Маршрут на нём пишется T-9->T-1, а в кадре это кортеж из двух кодов. Перевод собирают из двух деталей:

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

const Route = Schema.Tuple([Schema.String, Schema.String]);
const ROUTE = /^(T-\d+)->(T-\d+)$/;

export const RouteFromString = Schema.String.pipe(
  Schema.decodeTo(
    Route,
    SchemaTransformation.transformEffect({
      decode: (line: string) => {
        const [, from, to] = ROUTE.exec(line) ?? [];
        return from === undefined || to === undefined
          ? Effect.fail(new SchemaIssue.InvalidValue({ message: 'не маршрут' }, line))
          : Effect.succeed([from, to] as const);
      },
      encode: ([from, to]) => Effect.succeed(`${from}->${to}`),
    }),
  ),
);

Читаем снаружи внутрь. Schema.String.pipe(Schema.decodeTo(Route, перевод)): на проводе строка, в башне Route. Перевод это пара функций decode и encode между закодированной стороной Route и строкой. Чужая строка это не исключение, а беда разбора SchemaIssue.InvalidValue с текстом; Schema сама поставит ей путь и завернёт в SchemaError.

[ 'T-9', 'T-1' ] T-1->T-2

не маршрут

Перевод только меняет форму. Проверять смысл, коды и «башня не шлёт сама себе», будет схема на другой стороне: если вместо Route поставить кортеж с брендами, его проверки отработают после перевода. Когда перевод не может провалиться, вместо transformEffect берут SchemaTransformation.transform с обычными функциями.

Под капотом

Любая схема это значение с полем ast, деревом SchemaAST. Узел дерева описывает сторону башни: Objects для структуры, String, Number, Declaration для DateTime.Utc. У узла, стороны которого различаются, есть цепочка encoding: шаги перевода, и у каждого шага своя цель (схема, к которой он ведёт) и пара функций.

console.log(Schema.DateTimeUtcFromString.ast._tag, Schema.DateTimeUtcFromString.ast.encoding?.map((link) => link.to._tag));
Declaration [ 'String' ]

Узел Declaration, время, и один шаг до String. Запись идёт по цепочке от узла к проводу, разбор по той же цепочке обратно, от провода к узлу. Поэтому одна схема переводит в обе стороны, и поэтому decodeTo пишется со стороны провода: он дописывает шаг в цепочку. Есть и Schema.flip(S), которая меняет стороны местами: её разбор это запись S. Стороны по отдельности, без переводов, дают Schema.toType(S) и Schema.toEncoded(S).

Где тут дыра

const Naive = Schema.Struct({ id: Schema.Number, sentAt: Schema.Date });

const line = '{"id":7,"sentAt":"2026-11-01T06:00:00Z"}';
console.log(Effect.runSync(Effect.flip(Schema.decodeUnknownEffect(Schema.fromJsonString(Naive))(line))).message);
Expected a valid Date
  at ["sentAt"]

Время в строке правильное, а разбор говорит, что ждал правильную дату. Ринцвальд час ищет опечатку в строке. Дело не в строке. Schema.Date это схема значения Date: обе её стороны Date, объект JavaScript. Она годится, когда дата уже объект, например внутри башни. На проводе JSON, а JSON переносит дату только строкой. Нужен перевод: Schema.DateFromString даст Date, Schema.DateTimeUtcFromString даст DateTime.Utc. Правило: смотри, что лежит на проводе, и выбирай схему, у которой такая сторона Encoded.

Магистраль, этап 4

Кадр учится двум языкам: время отправки на проводе строкой, в башне DateTime.Utc; весь кадр Магистрали одной строкой JSON; и старый провод самой башни, маршрут|номер|тег|данные, как перевод в тот же кадр и обратно. Все проверки кадра работают после перевода, и README называет, что теряется при записи на старый провод.

Практика

Дальше

Рыдкулл заходит в мастерскую с пачкой разобранных кадров.

Классы схем ждут в следующем уроке.