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

Почему отказ: SchemaIssue

middle~20 мин

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

Схемы из уроков 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

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

Практика

Дальше

Пондер приносит карту Магистрали. Маршрут на ней не пара башен, а длинная цепочка, и у некоторых звеньев стоит вопросительный знак.

Схемы, которые ссылаются на себя и спрашивают сервисы, ждут в следующем уроке.