Схемы из уроков 76-80 уже отказывают: битый кадр не проходит разбор. Но отказ пока читает только программист, и только первую беду. Гильдии нужно другое: список, где каждая строка говорит, где и что не так. Весь этот список у Schema уже есть, его надо только достать.
Первая беда и все беды
Возьмём кадр, в котором ошибок пять, и разберём его конвертом Envelope из прошлого урока:
const messy = { id: 0, route: ['T-1'], priority: 'high', message: { kind: 'ack' }, sentAt: 'утром' };
Schema.decodeUnknownEffect(Envelope)(messy);
номер кадра больше нуля
at ["id"]
Одна строка. Разбор нашёл первую беду и остановился. Для программы это разумно: кадр всё равно не годится, зачем тратить время на остальное. Для гильдии это пинг-понг: исправили номер, прислали, получили отказ про маршрут, исправили, получили про срочность.
Вторым аргументом разбор принимает настройки. { errors: 'all' } просит идти до конца:
Schema.decodeUnknownEffect(Envelope)(messy, { errors: 'all' });
номер кадра больше нуля
at ["id"]
Missing key
at ["route"][1]
Expected "low" | "normal" | "urgent"
at ["priority"]
Missing key
at ["headers"]
Missing key
at ["message"]["of"]
Expected a valid UTC DateTime string
at ["sentAt"]
Шесть бед, и у каждой путь. Посмотри на message.of: в сообщении kind: 'ack', и Schema поняла, что из четырёх вариантов Message имелось в виду подтверждение, поэтому жалуется на его пропущенное поле, а не на то, что вход не похож ни на Text, ни на Ping.
Дерево бед
Текст выше это только вид. Внутри SchemaError лежит поле issue, и это дерево бед. Вот оно на маленькой схеме из трёх полей кадра:
const Slip = Schema.Struct({ id: FrameId, priority: Priority, headers: Headers });
Schema.decodeUnknownEffect(Slip)({ id: 0, priority: 'high' }, { errors: 'all' });
Composite
Pointer ["id"]
Composite
Filter
InvalidValue
Pointer ["priority"]
AnyOf
Pointer ["headers"]
MissingKey
Читается сверху вниз, как адрес на конверте. Composite это «здесь несколько бед». Pointer это «спустись по ключу». Листья говорят, что случилось: MissingKey нет поля, AnyOf вход не подошёл ни под один из вариантов, Filter с InvalidValue не прошла проверка из урока 78. Ещё бывают InvalidType (ждали число, пришла строка), UnexpectedKey (лишнее поле там, где их запретили) и Encoding (не удалось преобразование, как с датой в уроке 79).
Обходить это дерево руками можно, но незачем: обход уже написан.
Форматтер
Форматтер превращает дерево в плоский список. В модуле SchemaIssue их два готовых. makeFormatterDefault даёт тот текст, что ты видел выше. makeFormatterStandardSchemaV1 даёт список объектов { path, message }:
const formatter = SchemaIssue.makeFormatterStandardSchemaV1();
formatter(error.issue).issues;
[
{ path: [ 'id' ], message: 'номер кадра больше нуля' },
{ path: [ 'priority' ], message: 'Expected "low" | "normal" | "urgent"' },
{ path: [ 'headers' ], message: 'Missing key' }
]
Из такого списка строка для людей собирается в одну строку кода: путь через точку, двоеточие, сообщение. Беда всего входа, когда пришёл не объект, а строка 'мусор', идёт с пустым путём, и двоеточие ей не нужно.
Остаётся Missing key. Ткачи не читают по-английски, а «нет поля» им понятно. У форматтера есть ручка leafHook: функция, которая получает каждый лист дерева и отвечает текстом. Свой текст пишем только для MissingKey, остальное отдаём стандартному SchemaIssue.defaultLeafHook.
Тексты прямо в схеме
Второй путь к понятным словам идёт через саму схему. Свои тексты у проверок ты уже писал в уроке 78: isPattern(..., { message: 'код башни вида T-число' }). Так же можно подписать целую схему и пропущенный ключ:
const Priority = Schema.Literals(['low', 'normal', 'urgent']).annotate({
message: 'срочность: low, normal или urgent',
});
const Slip = Schema.Struct({
priority: Priority,
headers: Schema.Record(Schema.String, Schema.String).annotateKey({
messageMissingKey: 'служебные строки обязательны',
}),
});
срочность: low, normal или urgent
at ["priority"]
служебные строки обязательны
at ["headers"]
Когда что брать? Текст, который знает про смысл поля, пиши в схеме: он едет вместе с ней во все места. Перевод общих слов вроде Missing key делай в форматтере: он один на все схемы.
Отказ, который понимают чужие библиотеки
Список { path, message } выбран не случайно. Это формат Standard Schema, общего договора библиотек проверки на TypeScript. Schema.toStandardSchemaV1(Slip) отдаёт объект, который поймёт библиотека форм или роутер, даже если о Effect она не слышала. Гильдия, которая пишет на чём-то своём, получит отказ в знакомом ей виде.
Где тут дыра
const accept = (input: unknown): string => {
try {
Schema.decodeUnknownSync(Envelope)(input);
return 'принято';
} catch {
return 'invalid input';
}
};
На хорошем кадре работает: «принято». На битом тоже работает, в смысле не падает. Это и есть отказ ткачей, только теперь его шлём мы. Ринцвальд выбросил всё, что Schema собрала: путь, причину, остальные беды. Гильдия будет присылать кадр снова и снова, пока не угадает. А catch {} без имени ошибки ловит заодно и дефект: если в разборе упал наш собственный код, гильдия тоже прочитает «invalid input» и будет искать беду у себя. Это та же улика, что catch {} Магистрали из урока 46.
Лечится тем, что ошибка разбора остаётся значением в E, а наружу уходит её перевод словами.
Магистраль, этап 6
Академия учится отказывать по-человечески. Конверт разбирается до последней беды, каждая беда превращается в строку путь: что не так, а пачка кадров делится на принятые и отказы с номером входа. Пачка при этом не падает: отказ это нормальный ответ, а не ошибка.
Практика
Дальше
Пондер приносит карту Магистрали. Маршрут на ней не пара башен, а длинная цепочка, и у некоторых звеньев стоит вопросительный знак.
Схемы, которые ссылаются на себя и спрашивают сервисы, ждут в следующем уроке.