Runtime-валидация в TypeScript: unknown, Zod и проверенные данные
Автор: Казачкин Даниил Михайлович · Обновлено
TypeScript проверяет программу до запуска, но внешний объект не обязан соответствовать нашей аннотации. Runtime-валидация превращает неизвестный вход в понятный результат:…
TypeScript проверяет программу до запуска, но внешний объект не обязан соответствовать нашей аннотации. Runtime-валидация превращает неизвестный вход в понятный результат: проверенные и при необходимости нормализованные данные либо описание нарушенных правил. Построим такую границу для импорта урока с помощью Zod 4.
Цель и подготовка
Вы научитесь принимать unknown, описывать схему, обрабатывать обе ветки safeParse и использовать полученный выходной тип без ручного копирования полей. До урока нужны сужение типов и различие JSON-синтаксиса и структуры данных. Для воспроизведения подготовьте отдельный ESM-проект с Node.js 24, TypeScript 6, strict и настройками NodeNext из урока tsconfig.
pnpm add zod@4Версию компилятора и @types/node установите по предыдущему уроку; эта команда добавляет только библиотеку проверки в учебный проект. Zod является runtime-зависимостью примера, поскольку его функции исполняются после компиляции. Это отличается от интерфейса TypeScript, который исчезнет из JavaScript.
Схема как исполняемая граница
Сохраните следующий фрагмент в src/main.ts, скомпилируйте проект и запустите выходной main.js. Код полностью автономен: он не требует сервера, формы или реального ответа API.
import * as z from 'zod';
const LessonImport = z.strictObject({
title: z.string().trim().min(1),
minutes: z.number().int().min(1).max(240),
tags: z.array(z.string().trim().min(1)).max(3).default([]),
});
type Lesson = z.output<typeof LessonImport>;
function inspectImport(input: unknown): string {
const result = LessonImport.safeParse(input);
if (!result.success) {
return result.error.issues
.map((issue) => issue.path.map(String).join('.') || '<root>')
.join(', ');
}
const lesson: Lesson = result.data;
return JSON.stringify(lesson);
}
console.log(inspectImport({ title: ' Замыкания ', minutes: 45 }));
console.log(inspectImport({ title: ' ', minutes: 0, tags: [' '] }));
console.log(inspectImport({ title: 'Модули', minutes: '45' }));
console.log(inspectImport({ title: 'Модули', minutes: 45, hidden: true }));
if (false) {
// @ts-expect-error Выход схемы всегда содержит tags после default.
const incomplete: Lesson = { title: 'Модули', minutes: 45 };
console.log(incomplete);
}Первая строка вывода — {"title":"Замыкания","minutes":45,"tags":[]}. Следующие строки — title, minutes, tags.0, затем minutes, затем <root>. Вместо привязки к англоязычному тексту библиотечной ошибки пример выводит пути проблемных полей. Ошибка неизвестного свойства относится ко всему объекту, поэтому получает отдельное обозначение корня.
Первый вход изменился при разборе: пробелы вокруг названия удалены, а отсутствующие tags получили пустой массив. После успеха используйте result.data. Если проверить вход, а потом продолжить работать с исходным объектом, нормализованное название и добавленные значения потеряются. Проверка и получение результата здесь являются одной операцией.
Входной тип может отличаться от выходного
z.infer и z.output описывают выход схемы, а z.input — её статически допустимый вход. В нашем контракте tags можно не передавать, но после успешного разбора поле существует. Более заметное отличие появляется при преобразовании типа значения. Следующий независимый пример можно сохранить отдельным файлом.
import * as z from 'zod';
const LabelLength = z.string().trim().min(1).transform((value) => value.length);
type Before = z.input<typeof LabelLength>;
type After = z.output<typeof LabelLength>;
const source: Before = ' тип ';
const length: After = LabelLength.parse(source);
console.log(length);
if (false) {
// @ts-expect-error После преобразования результат является числом.
const wrong: After = '3';
console.log(wrong);
}Вывод — 3. Схема сначала требует строку, затем обрезает пробелы и проверяет непустоту, после чего возвращает длину. Наличие понятного входного типа не отменяет unknown на внешней границе: мы не знаем заранее, что реально пришло по сети или из файла. Схема как раз проверяет это предположение.
Политика дополнительных полей и преобразований
В основном примере strictObject отвергает лишние свойства. Обычный z.object по умолчанию удаляет неизвестные ключи из результата, а z.looseObject сохраняет их. Выбор зависит от назначения границы. Для формы импорта лишнее поле может означать опечатку; для совместимого чтения расширяемого ответа сервера другая политика иногда удобнее.
Не заменяйте number на coerce.number только потому, что форма присылает строки. Coercion использует правила преобразования JavaScript: пустая строка и другие неожиданные входы могут превратиться в значения, которые вы не собирались принимать. Аналогично строка "false" при обычном Boolean-преобразовании истинна. Сначала опишите разрешённый текстовый формат, затем выполните явное преобразование и проверку диапазона.
Порядок действий тоже влияет на смысл. Проверка min до trim и после trim отвечает на разные вопросы о строке из пробелов. В нашем примере нужно непустое содержимое после нормализации. Такая договорённость заслуживает теста, потому что на обычном красивом названии оба порядка дают одинаковый результат.
Ошибки и предметные правила
parse выбрасывает ошибку при несоответствии, а safeParse возвращает результат с признаком success. Для ожидаемых ошибок пользовательского ввода второй вариант позволяет описать ветки явно. Если схема содержит асинхронные проверки или преобразования, нужен асинхронный вариант разбора; добавлять async в callback и оставлять синхронный вызов нельзя.
Пути issues подходят для привязки сообщений к полям. Для вложенной формы можно использовать z.treeifyError, для общего текста — z.prettifyError. В рабочем интерфейсе объясняйте человеку, что исправить, а не показывайте внутреннюю структуру библиотеки. Системная ошибка чтения файла и неверное поле — разные ситуации, их не следует смешивать в один ответ «невалидно».
Схема ещё не подтверждает, что пользователь имеет право менять данный урок или что идентификатор существует в базе. Такие решения требуют контекста приложения. Задача этой границы — получить данные нужной формы и диапазона, чтобы следующая операция могла работать с ними осмысленно.
Типичные ошибки
Самая опасная короткая запись — привести внешний объект через as к ожидаемой модели и назвать это валидацией. Второй вариант той же ошибки — подавить сообщение компилятора через any. В обоих случаях исполняемой проверки не появляется. Не дублируйте интерфейс рядом со схемой без причины: вывод типа уменьшает риск их расхождения.
Не оборачивайте каждое внутреннее обращение к уже проверенному полю повторным safeParse. Выделите границы, на которых меняется доверие к данным: чтение внешнего значения, сохранённый старый формат, сообщение другого процесса. Внутри согласованной операции полезнее удержать проверенный тип и не терять его случайными утверждениями.
Практика с разбором
Добавьте необязательное поле sourceUrl, но разрешите только непустую строку после trim, пока без проверки протокола URL. Проверьте отсутствие, пустую строку, пробелы и нормальную строку. Решите отдельно, допускается ли null. Сформулируйте ожидаемый результат до изменения схемы.
Разбор: для заданной задачи подходит z.string().trim().min(1).optional(). Отсутствие проходит, пустые значения отклоняются; null не становится допустимым от слова optional. Если он несёт отдельный смысл, это выражается через nullable. Отдельная проверка настоящего URL может быть следующим требованием, но её нельзя приписывать простой проверке непустой строки.
Частые вопросы
Можно ли сразу указать тип в JSON.parse?
Утверждение типа после разбора не проверяет значение. Даже библиотечная функция с generic должна иметь реализацию, которая обосновывает возвращаемую гарантию. Для внешнего JSON нужны разбор синтаксиса и проверка структуры; схема обеспечивает вторую часть.
Заменяет ли runtime-валидация строгую компиляцию?
Нет. Схема проверяет конкретный вход во время исполнения, а TypeScript помогает правильно использовать результат во всех ветках программы. Их совместная работа особенно заметна на safeParse: не проверив success, нельзя безосновательно обращаться к data.
Связанные исследования
- Utility types как преобразования моделей: что доказывают Pick, Omit, Partial и Record — Проверяем границы преобразований моделей: почему Omit не удаляет данные, Partial меняет состояния команды, Record зависит от ключей, а union может потерять связи.
- Что означает «TypeScript обнаруживает 15% ошибок»: критическое чтение исследования ICSE 2017 — Критический разбор ICSE 2017: 400 исторических ошибок, Flow 0.30 и TypeScript 2.0, обнаружимость 15%, ограничения метода и собственный протокол оценки пользы типов.