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

Разбирай, а не проверяй

middle~20 мин

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

* В гильдии печатников говорят, что печать ничего не доказывает, кроме того, что кто-то её поставил. Поэтому Казимир, прежде чем поставить свою, читает письмо до последней запятой. Гильдии жалуются, что это долго. Ни одна ещё не пожаловалась, что письмо оказалось не тем.

Башня T-1 до сих пор читала только свой провод: маршрут|номер|тег|данные. Его формат придумала Академия, и decodeFrame из урока 37 знал о нём всё. Теперь кадры приходят от Магистрали, в её формате, и Академия их не писала. Вопрос раздела: как принять чужой кадр и не поверить ни одному его полю.

Записка Магистрали

Самое простое, что шлёт Магистраль, это записка: номер, откуда, куда и текст.

{"id":7,"from":"T-9","to":"T-1","text":"гроза"}

JSON.parse превращает эту строку в объект, и тип у объекта any. Компилятор умывает руки: что ни напиши дальше, всё соберётся. Значит, кто-то должен посмотреть на объект и решить, записка это или нет. Путей два.

Проверить и забыть

Первый путь знаком по уроку 06: функция-предикат с is.

interface Note {
  readonly id: number;
  readonly from: string;
  readonly to: string;
  readonly text: string;
}

const isNote = (value: unknown): value is Note =>
  typeof value === 'object' &&
  value !== null &&
  typeof (value as Record<string, unknown>)['id'] === 'number';

У предиката три беды.

  • Он отвечает true или false. Почему кадр не годится, он не скажет: ни поле, ни что в нём ждали.
  • Компилятор верит слову is на слово. Этот предикат проверяет только id, а сужает до целой записки. Объект { id: 7 } пройдёт проверку, и note.text.length упадёт в рантайме.
  • Знание о проверке живёт в одной ветке if. Вышел из неё, передал значение дальше, и тип снова тот же Note, проверенный или нет, по нему не видно.

Такой путь называют проверкой (validate): посмотрел на значение, ответил да или нет, а значение осталось каким было.

Разобрать

Второй путь называют разбором (parse): функция берёт что угодно и отдаёт либо записку, либо причину, почему это не записка. Ответ разбора это уже другое значение, с другим типом, и проверку несёт он сам. Формула известна как parse, don’t validate: разбирай, а не проверяй.

Писать такой разбор руками для каждого формата долго, и в Effect для этого есть модуль Schema. Схема это бланк договора: какие поля, какого вида.

import { Effect, Schema } from 'effect';

export const Note = Schema.Struct({
  id: Schema.Number,
  from: Schema.String,
  to: Schema.String,
  text: Schema.String,
});

export type Note = typeof Note.Type;

Schema.Struct собирает схему объекта из схем полей. Тип записки руками не пишут: typeof Note.Type выводит его из схемы, и это тот же { readonly id: number; readonly from: string; readonly to: string; readonly text: string }, что был у интерфейса. Поменяешь схему, поменяется тип: разойтись им негде.

Разбирает схема через Schema.decodeUnknownEffect:

export const decodeNote = (input: unknown): Effect.Effect<Note, Schema.SchemaError> =>
  Schema.decodeUnknownEffect(Note)(input);

console.log(Effect.runSync(decodeNote({ id: 7, from: 'T-9', to: 'T-1', text: 'гроза', extra: 1 })));
{ id: 7, from: 'T-9', to: 'T-1', text: 'гроза' }

Вход unknown: разбору всё равно, откуда объект. Лишнее поле extra в ответ не попало: схема знает только свои поля. А вот кадр, где номер пришёл строкой:

const error = Effect.runSync(Effect.flip(decodeNote({ id: '7', from: 'T-9', to: 'T-1', text: 'гроза' })));
console.log(error._tag);
console.log(error.message);
SchemaError
Expected number
  at ["id"]

Ответ не false, а ошибка с тегом SchemaError и разбором: в поле id ждали число. Ошибка лежит в канале E, как любая ошибка дела из урока 39: decodeNote не бросает, и catchTag('SchemaError', ...) её ловит.

Разбор останавливается на первой беде. Чтобы собрать все, ему передают настройку { errors: 'all' }; как показать их человеку, разберём в уроке 81.

Договор в обе стороны

Схема не только читает. Тот же бланк пишет записку обратно, для провода:

export const encodeNote = (note: Note): Effect.Effect<typeof Note.Encoded, Schema.SchemaError> =>
  Schema.encodeEffect(Note)(note);

У каждой схемы два типа. Type это значение в башне, Encoded это то, что лежит на проводе. У записки они совпадают: числа и строки JSON переносит как есть. В уроке 79 они разойдутся: время на проводе строка, а в башне время.

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

А Zod?

Если ты писал на TypeScript, ты, скорее всего, встречал Zod. Он тоже разбирает, а не проверяет: z.object({...}).parse(x) отдаёт значение или ошибку, а тип выводится из схемы. Сама мысль общая. Разница в том, что нужно башне: схема Effect умеет ещё и записывать значение обратно (у неё две стороны, Type и Encoded), а разбор отдаёт квитанцию с ошибкой в типе, которую башня складывает с остальными через yield*. Если в проекте уже есть Effect, второй язык договоров не нужен.

Где тут дыра

const line = '{"id":"7","from":"T-9","to":"T-1","text":"гроза"}';
const note = JSON.parse(line) as Note;
const sent = new Set([7]);

console.log(note.id + 1, sent.has(note.id));
71 false

as не проверяет ничего: это обещание компилятору, что ты знаешь лучше. Магистраль прислала номер строкой, и строка дошла до самой книги отправленных. note.id + 1 дал '71', а книга не нашла кадр 7, хотя он там есть. Ни одной ошибки, только неверный ответ, и найти его будет трудно: тип говорит number. С разбором это была бы ошибка Expected number at ["id"] на входе, ровно там, где кадр пришёл.

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

Новое изделие раздела, magistral: протокол кадров Магистрали, по которому Академия их принимает и пишет. Первый этап самый простой бланк, записка, и её разбор и запись. Ошибки разбора дает сама схема, README называет их дословно.

Практика

Глубже в архиве

Чем тип отличается от проверки и где их граница в TypeScript, разбирает урок «Надёжность и типы на границах».

Дальше

Казимир берёт кадр Магистрали целиком и раскладывает его на столе поле за полем.

Бланк из многих полей ждёт в следующем уроке.