* В гильдии печатников говорят, что печать ничего не доказывает, кроме того, что кто-то её поставил. Поэтому Казимир, прежде чем поставить свою, читает письмо до последней запятой. Гильдии жалуются, что это долго. Ни одна ещё не пожаловалась, что письмо оказалось не тем.
Башня 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, разбирает урок «Надёжность и типы на границах».
Дальше
Казимир берёт кадр Магистрали целиком и раскладывает его на столе поле за полем.
Бланк из многих полей ждёт в следующем уроке.