Zod discriminatedUnion: проверяем события обучения по признаку kind
Автор: Казачкин Даниил Михайлович · Обновлено
События одного потока часто имеют разные наборы полей: начало урока, ответ на вопрос и завершение прохождения. Zod discriminatedUnion позволяет выбрать схему по общему признаку и проверить именно нужный вариант. Построим проверку входного события, а затем обработчик, в котором TypeScript требует учесть все допустимые случаи.
Цель и предварительные знания
После урока вы сможете спроектировать проверяемый формат событий без большого объекта с необязательными полями. Нужны обычные discriminated unions, never и базовый safeParse. Окружение — Node.js 24, TypeScript 6 в strict/NodeNext-проекте и Zod 4. Установите библиотеку в отдельную учебную папку, как в предыдущем уроке.
pnpm add zod@4Здесь важны две последовательные гарантии. Сначала runtime-схема отвергает неизвестное значение неправильной формы. После успеха статический тип позволяет выбрать поля правильной ветки без as. Если оставить только вторую часть, внешний объект с выдуманным kind сможет дойти до обработчика.
Три формы одного события
Сохраните весь следующий фрагмент в src/main.ts, соберите проект и запустите результат. Каждая ветка имеет обязательный литеральный kind. Поля других веток не объявлены как optional: для данного варианта они не являются частью контракта.
import * as z from 'zod';
const LessonId = z.string().min(1);
const ProgressEvent = z.discriminatedUnion('kind', [
z.strictObject({
kind: z.literal('started'),
lessonId: LessonId,
}),
z.strictObject({
kind: z.literal('answered'),
lessonId: LessonId,
correct: z.boolean(),
elapsedSeconds: z.number().int().min(0),
}),
z.strictObject({
kind: z.literal('finished'),
lessonId: LessonId,
score: z.number().int().min(0).max(100),
}),
]);
type Progress = z.infer<typeof ProgressEvent>;
function unreachable(value: never): never {
throw new Error(`Неизвестное событие: ${JSON.stringify(value)}`);
}
function describeProgress(event: Progress): string {
switch (event.kind) {
case 'started':
return `Начат ${event.lessonId}`;
case 'answered':
return `Ответ ${event.correct ? 'верный' : 'неверный'}, ${event.elapsedSeconds} с`;
case 'finished':
return `Завершён ${event.lessonId}: ${event.score}%`;
default:
return unreachable(event);
}
}
function inspectEvent(input: unknown): string {
const result = ProgressEvent.safeParse(input);
if (!result.success) return 'Событие отклонено';
return describeProgress(result.data);
}
console.log(inspectEvent({ kind: 'started', lessonId: 'ts-01' }));
console.log(inspectEvent({
kind: 'answered', lessonId: 'ts-01', correct: false, elapsedSeconds: 0,
}));
console.log(inspectEvent({ kind: 'finished', lessonId: 'ts-01', score: 100 }));
console.log(inspectEvent({ kind: 'finished', lessonId: 'ts-01' }));
console.log(inspectEvent({ kind: 'paused', lessonId: 'ts-01' }));
if (false) {
// @ts-expect-error У finished обязательно есть score.
const incomplete: Progress = { kind: 'finished', lessonId: 'ts-01' };
console.log(incomplete);
}Ожидаемый вывод: Начат ts-01, Ответ неверный, 0 с, Завершён ts-01: 100%, затем две строки Событие отклонено. Нулевое время и false являются допустимыми данными. Проверки через truthiness отвергли бы их ошибочно; выбранные схемы проверяют нужный тип и конкретный диапазон.
Обратите внимание на последнее ожидаемое сообщение компилятора. Обязательность score видна уже в типе, выведенном из схемы. При этом предыдущий runtime-вызов с тем же отсутствующим полем тоже отклоняется. Проверка известного литерала и проверка внешнего unknown согласованы, поскольку описание формы не скопировано в отдельный интерфейс.
Почему общий optional-объект слабее
Если описать kind как строку, а correct, elapsedSeconds и score сделать необязательными полями одного объекта, компилятор не узнает зависимость между видом события и содержимым. Даже после проверки kind обработчик вынужден проверять score заново. Ещё хуже, становится легко создать завершение без оценки или начало с чужими полями.
В объединении каждый вариант имеет собственную форму. Дискриминатор является частью данных и должен быть устойчивым соглашением протокола. Не вычисляйте его из случайного наличия поля, если значения можно передавать явно. При развитии формата лучше добавить новый вариант с определённым смыслом, чем менять значение старого kind незаметно для потребителей.
Обычный z.union проверяет альтернативы, а discriminatedUnion использует общий признак для выбора варианта. Не стоит обещать ускорение любого приложения на основании одного названия метода: размер данных и сама работа схемы тоже имеют значение. Главная польза в нашем случае — ясная модель, однозначные ветки и понятная диагностика неправильного события.
Неизвестные поля и неизвестные варианты
strictObject в каждой ветке запрещает дополнительные ключи. Например, started со score будет отклонён, хотя оба имени где-то встречаются в полном объединении. Проверяется выбранная форма, а не общий мешок всех полей. Если протокол допускает расширение метаданными, решите это отдельно — например, явным полем metadata с собственной схемой.
Неизвестный kind также отклоняется. Это может быть опечатка или более новая версия отправителя. Для системы, которая должна пересылать неизвестные события без обработки, понадобится отдельная стратегия хранения и версионирования. Не расширяйте kind до произвольной строки внутри обычной ветки только ради исчезновения ошибок: это разрушит предсказуемую модель обработчика.
Проверка формы не является автоматом состояний
Получив корректное finished, мы ещё не доказали, что перед ним пришёл started. Схема не знает историю пользователя. Она также не проверяет, разрешено ли повторное событие и совпадает ли lessonId с существующим уроком. Эти правила живут в обработке потока, где доступны предыдущие состояния и хранилище.
Разделение помогает тестировать точные вещи. Для схемы важны отсутствующие поля, неверные типы и граничные значения. Для переходов важен порядок: повторный ответ, завершение до начала, повторная доставка одного идентификатора события. Не пытайтесь спрятать всю историю в одной функции проверки JSON-объекта.
Ошибки и расширение обработчика
Основной пример возвращает короткое сообщение, чтобы сосредоточиться на ветвлении. Для настоящей формы используйте result.error.issues и пути полей, а для общего представления — подходящий форматтер Zod. Не полагайтесь на точный текст стандартного сообщения как на стабильный машинный код протокола.
Функция unreachable полезна после успешной валидации: если в схему добавляется новый вариант, выведенный Progress меняется, и switch перестаёт быть полным. Runtime-исключение внутри этой функции остаётся защитой от нарушений в JavaScript, но основная цель — поймать пропущенную ветку при компиляции. Пустой default, молча возвращающий успех, скрыл бы изменение контракта.
Практика с разбором
Добавьте событие paused с lessonId и обязательной причиной break или technical. Сначала измените только схему и запустите tsc. Затем добавьте обработчик, который возвращает разные русские подписи для двух причин. Проверьте допустимые причины, неизвестную строку, отсутствующую причину и лишний score.
Разбор: новая ветка использует z.literal('paused') для kind и z.enum(['break', 'technical']) для reason внутри strictObject. До изменения switch передаваемый в unreachable event перестанет иметь тип never: останется paused. После явного case тип снова сузится полностью. Неизвестная причина и недостающий reason отклоняются схемой, а дополнительный score — политикой выбранного объекта.
Частые вопросы
Нужно ли применять discriminatedUnion к любому объединению?
Нет. Для числа или строки подходит обычный union, поскольку общего объектного признака нет. Выбирайте discriminatedUnion, когда варианты действительно являются объектами и формат содержит устойчивое поле, по которому можно выбрать вариант.
Можно ли дважды использовать один и тот же kind?
Для однозначного выбора варианты должны различаться допустимыми значениями дискриминатора. Если один kind скрывает несколько содержательных форм, добавьте дополнительный понятный признак, измените модель либо выберите другой способ проверки. Не рассчитывайте на случайный порядок веток как на контракт данных.