Перейти к содержимому
Поиск
Разработка

Zod: проверка данных в JavaScript и TypeScript — мастерская и Node.js API

Проверяем данные настоящей библиотекой Zod прямо на странице. Разбираем формы, JSON и настройки, читаем ошибки и запускаем готовый Node.js API локально или на Linux-сервере.

Александр Некрасов
Zod — проверка форм, JSON и данных TypeScript

Форма присылает строки, внешний API меняет ответ, в настройках вместо порта оказывается пробел, а интерфейс ожидает аккуратный объект с нужными типами. Zod позволяет описать этот объект один раз и проверить реальные данные в момент выполнения программы.

В этой статье разберём проверку данных в JavaScript и TypeScript на задачах, которые встречаются в обычных проектах: заявки, настройки, JSON интеграций, пагинация и разные варианты сообщений. На странице работает мини-мастерская с настоящей библиотекой. Можно испортить значение, получить ошибку, увидеть путь до проблемного поля и скачать исходную схему. В архиве также находится Node.js API: он повторно проверяет запросы на сервере и запускается локально или отдельным сервисом на Linux VPS.

Что такое Zod и какие задачи она решает

Zod — библиотека для описания структуры данных и проверки значений во время работы программы. Схема может требовать строку, число в диапазоне, email, массив товаров, вложенный адрес или один из нескольких вариантов объекта. На вход она получает значение, которому приложение пока не доверяет. На выходе вы получаете проверенные данные либо структурированное описание ошибок.

Практическая польза появляется на границах приложения. Пользователь отправил форму — проверяем поля. Другой сервис вернул JSON — убеждаемся, что цена действительно число. Процесс запустился с переменными окружения — проверяем порт и URL до первого запроса. Обработчик фоновой задачи получил payload — отклоняем неподходящий формат до выполнения дорогой операции. Вместо разрозненных проверок в нескольких функциях появляется читаемый контракт.

При этом сама библиотека не рисует форму, не запускает HTTP-сервер и не хранит записи. В наших примерах интерфейс написан на HTML5 и Bootstrap5, сервер — на стандартном модуле Node.js, а Zod отвечает за правила данных. Так легче увидеть, где заканчивается валидация и начинается остальная логика приложения.

Ситуация Что проверяем Что получает приложение
Форма заявки Обязательные поля, email, согласие Ошибки для полей и нормализованный объект
Интеграция с API Типы, обязательные ключи, вложенные элементы Предсказуемый контракт ответа
Настройки запуска Строковые значения окружения Числа и флаги нужных типов
HTTP API Query string и JSON body Ограниченные параметры до работы с БД
Разные варианты команды Поле-дискриминатор и поля ветки Правильная форма конкретной операции

Документация: Официальный сайт Zod.

Почему TypeScript недостаточно для входящих данных

TypeScript помогает разработчику до запуска программы: замечает несовместимые типы и подсказывает свойства. Но ответ HTTP-запроса не становится правильным только потому, что рядом написан интерфейс. Сеть, форма, файл и локальное хранилище присылают фактические значения. В них может отсутствовать ключ, вместо числа оказаться строка, а вложенный объект быть null.

Особенно опасно воспринимать конструкцию as Product как проверку. Это утверждение разработчика для компилятора, которое не изучает JSON. Если сервер вернул price: "дорого", арифметика всё равно сломается. Zod выполняет проверку над значением. После успешного разбора TypeScript может вывести тип результата из той же схемы, поэтому правила и тип не приходится поддерживать независимо.

import * as z from 'zod';

const Product = z.object({
  id: z.uuid(),
  title: z.string().min(1),
  price: z.number().nonnegative()
});

const raw = await response.json(); // Пока это внешние данные.
const product = Product.parse(raw);
console.log(product.price * 2); // Работаем с проверенным результатом.

В небольшом скрипте иногда достаточно простой ручной проверки. Схемы особенно полезны, когда формат разрастается, появляется несколько точек входа и ошибки нужно показывать человеку. Не обязательно начинать со всего приложения: можно закрыть одну важную границу, например оформление заказа или загрузку конфигурации.

Мини-мастерская: восемь живых сценариев

Сначала выберите задачу и загрузите корректный пример. После проверки сравните вход и результат: крайние пробелы исчезнут, некоторые строки станут числами, неизвестное поле из ответа API пропадёт. Затем загрузите пример с ошибками. Исправляйте значения по одному и наблюдайте, как меняются сообщения и дерево ошибок.

Для простых задач доступны обычные поля формы и редактор JSON. Сложные объекты редактируются в JSON целиком. Вкладка «Данные» показывает нормализованный результат или issues, «Дерево ошибок» помогает разобраться с вложенностью, «Схема Zod» содержит код конкретного сценария. Активную вкладку можно скопировать или скачать отдельным файлом.

На этой странице настоящая Zod работает в браузере. Введённые значения не отправляются API сайта. Проверка занятых логинов использует фиксированный учебный список. В скачанном Node.js-проекте появляется отдельная кнопка серверной проверки: она действительно делает HTTP-запрос к запущенному вами API. Это два разных места выполнения одного контракта.

Установка и устройство готового проекта

В примерах используется Zod 4.6.7. Эта версия закреплена в package.json и package-lock.json, поэтому мастерская и сервер собираются с одинаковой библиотекой. Для проекта выбран Node.js 24. TypeScript нужен только для примера вывода типов и команды проверки; запуск сервера выполняется обычным JavaScript без отдельного TypeScript runtime.

Добавить библиотеку в свой проект

npm install zod@4.6.7 --save-exact

Для готового архива используйте npm ci: команда устанавливает зависимости по lockfile. Скрипт build собирает общий модуль схем вместе с Zod в браузерный файл и создаёт читаемые исходники для вкладки мастерской. CDN не нужен. Изменили правило в src/validation.mjs — пересоберите браузерную часть, чтобы клиент и сервер снова использовали одну версию контракта.

Локальный запуск в Linux / macOS с установленным Node.js 24

cd zod-project
npm ci
npm run build
npm test
npm run typecheck
npm start

Откройте http://127.0.0.1:3097. Во втором терминале выполните npm run smoke: проверяются HTTP health, корректная пагинация и отказ при слишком большом лимите. Остановка сервера — Ctrl+C. Подробное размещение на Linux-сервере разобрано ниже, а Windows-команды вынесены отдельно.

Документация: Пакет Zod в npm.

parse, safeParse и проверенный результат

У схемы есть два основных способа сообщить о неудаче. parse возвращает результат или выбрасывает ZodError. Это удобно, когда ошибка должна прервать дальнейшую работу: например, приложение не может стартовать с неверными настройками. safeParse возвращает объект с флагом success. Такой вариант естественнее для пользовательской формы, где ошибочный ввод ожидаем и нужно показать сообщения, а не аварийно завершить обработчик.

const result = Product.safeParse(raw);
if (!result.success) {
  console.log(result.error.issues);
  // Вернуть ошибки интерфейсу и остановить обработку.
} else {
  const product = result.data;
  console.log(product.title, product.price);
}

Всегда продолжайте работу с result.data, а не с исходным raw. Именно результат содержит выполненные преобразования и выбранную политику неизвестных ключей. Распространённая ошибка — проверить объект, проигнорировать нормализованную копию и сохранить первоначальные строки с пробелами. В мастерской это легко заметить, сравнив email до и после разбора.

Для асинхронного правила существуют parseAsync и safeParseAsync. В общем исполнителе нашей мастерской используется второй вариант для всех сценариев: синхронные схемы тоже корректно обрабатываются, а асинхронная проверка имени получает необходимое ожидание.

Документация: Базовые способы разбора данных.

Пример 1. Форма заявки и понятные ошибки у полей

Представим форму обращения в CRM: имя, email, описание задачи и подтверждение учебного согласия. Человеку важно не только узнать, что запрос отклонён, но и понять, какое поле исправить. Поэтому общая надпись «Ошибка 422» недостаточна. Схема задаёт правила, а интерфейс сопоставляет путь каждой ошибки с контролом и показывает конкретное сообщение рядом.

Рабочая схема из проекта: form

import * as z from "zod";
function formSchema() {
  return z.strictObject({ name: z.string().trim().min(2, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 \u0438\u043C\u044F: \u043C\u0438\u043D\u0438\u043C\u0443\u043C 2 \u0441\u0438\u043C\u0432\u043E\u043B\u0430.").max(60), email: z.string().trim().toLowerCase().pipe(z.email({ error: "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 \u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 email." })), message: z.string().trim().min(10, "\u041E\u043F\u0438\u0448\u0438\u0442\u0435 \u0437\u0430\u0434\u0430\u0447\u0443 \u0445\u043E\u0442\u044F \u0431\u044B \u0432 10 \u0441\u0438\u043C\u0432\u043E\u043B\u0430\u0445.").max(300), consent: z.literal(true, { error: "\u041F\u043E\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u0435 \u0441\u043E\u0433\u043B\u0430\u0441\u0438\u0435 \u0434\u043B\u044F \u0443\u0447\u0435\u0431\u043D\u043E\u0439 \u0437\u0430\u044F\u0432\u043A\u0438." }) });
}
const Schema = formSchema();
const result = await Schema.safeParseAsync(input);
if (result.success) console.log(result.data);
else console.log(result.error.issues);

Здесь trim удаляет пробелы по краям, после чего проверяется длина. Email в нашем контракте дополнительно приводится к нижнему регистру: это выбранное правило учебной формы, а не обязательная функция любой email-проверки. Подтверждение должно быть именно boolean true. Строка "true" из произвольного JSON не проходит это правило автоматически.

Проверьте сценарий: загрузите ошибочный пример, затем исправьте только email. После следующей проверки остальные поля останутся ошибочными. Это полезный образец поведения для формы обратной связи или создания заявки. В реальном интерфейсе стоит сохранять заполненные значения после отказа, привязывать сообщение к полю через aria-describedby и не заставлять человека начинать всё заново.

В проекте успешный POST создаёт учебную запись в памяти. Это позволяет увидеть полный путь от формы до серверной проверки без настройки почты или базы. При переносе в свой сервис замените хранение отдельной операцией приложения и добавьте реальные правила согласия, обработки заявки и доступа к данным.

Пример 2. JSON внешнего сервиса и неизвестные поля

Даже собственный backend может измениться независимо от фронтенда, а сторонний API тем более. Проверять ответ полезно до построения карточек и вычислений. В примере ожидается товар с UUID, названием, числовой ценой и массивом меток. Если цена внезапно придёт строкой, схема обнаружит изменение контракта рядом с местом получения данных, а не позже в случайном компоненте.

Рабочая схема из проекта: json

import * as z from "zod";
function jsonSchema() {
  return z.object({ id: z.uuid(), title: z.string().min(1).max(80), price: z.number().nonnegative(), tags: z.array(z.string().min(1).max(24)).max(5).default([]) });
}
const Schema = jsonSchema();
const result = await Schema.safeParseAsync(input);
if (result.success) console.log(result.data);
else console.log(result.error.issues);

У корректного примера специально есть лишний internalNote. Обычный z.object убирает неизвестный ключ из результата. Это удобно, когда интеграция добавляет новые поля, а приложение использует только заранее выбранные. Важная деталь: исходный JSON остаётся прежним, поэтому нужно брать нормализованный результат.

Конструктор объекта Поведение с неизвестным ключом Когда подходит
z.object Удаляет ключ из результата Выбрать нужные поля внешнего ответа
z.strictObject Сообщает об ошибке Контракт команды или параметров API
z.looseObject Сохраняет ключ Расширяемый объект с осознанным passthrough

Это разные решения, а не уровни качества. Строгая схема помогает заметить опечатку в команде, но может без необходимости ломать интеграцию при добавлении поля провайдером. Сохраняющий объект удобен для расширений, однако его нельзя бездумно передавать в обновление модели: лишние административные поля должны быть исключены на уровне разрешённой операции.

Пример 3. Проверка настроек окружения при запуске

Переменные окружения обычно приходят строками. Порт "3097" ещё не число, а "false" ещё не логическое false. Если разбирать настройки в разных местах приложения, легко получить неодинаковую интерпретацию. Удобнее собрать небольшой объект нужных переменных, проверить его один раз и передавать дальше готовую конфигурацию.

Рабочая схема из проекта: config

import * as z from "zod";
function configSchema() {
  return z.strictObject({ APP_MODE: z.enum(["development", "production"]).default("development"), PORT: z.string().regex(/^\d+$/, "\u041F\u043E\u0440\u0442 \u0434\u043E\u043B\u0436\u0435\u043D \u0441\u043E\u0434\u0435\u0440\u0436\u0430\u0442\u044C \u0442\u043E\u043B\u044C\u043A\u043E \u0446\u0438\u0444\u0440\u044B.").transform(Number).pipe(z.number().int().min(1).max(65535)), DEBUG: z.stringbool(), PUBLIC_URL: z.url({ protocol: /^https?$/, error: "\u041D\u0443\u0436\u0435\u043D URL \u0441 \u043F\u0440\u043E\u0442\u043E\u043A\u043E\u043B\u043E\u043C http \u0438\u043B\u0438 https." }) });
}
const Schema = configSchema();
const result = await Schema.safeParseAsync(input);
if (result.success) console.log(result.data);
else console.log(result.error.issues);

Не передавайте весь process.env в strictObject: в окружении процесса есть много переменных системы и хостинга. Выберите нужные ключи явно. Не выводите объект конфигурации целиком в лог, если впоследствии добавите пароли или токены. Ошибка должна говорить, какая настройка нарушает правило, не раскрывая секретное значение.

import { makeSchema } from './src/validation.mjs';
const ConfigSchema = makeSchema('config');
const config = ConfigSchema.parse({
  APP_MODE: process.env.APP_MODE,
  PORT: process.env.PORT,
  DEBUG: process.env.DEBUG,
  PUBLIC_URL: process.env.PUBLIC_URL
});
// config.PORT — число, config.DEBUG — boolean.

В рабочем Node.js-сервере из архива проверяется собственный небольшой набор HOST и PORT. Привязка разрешена только к 127.0.0.1: запросы извне принимает reverse proxy. Переменная PORT задаётся окружением или получает значение 3097. Такая проверка делает ошибку конфигурации заметной при старте процесса.

Пример 4. Почему Number(“”) и Boolean(“false”) удивляют

Автоматическое преобразование короткое, но не всегда соответствует смыслу поля. JavaScript превращает пустую строку в ноль, а непустую строку "false" — в true. Поэтому механическое использование z.coerce.number() и z.coerce.boolean() может принять данные, которые форма должна отклонить. Это особенно заметно в фильтрах, query string и настройках.

Рабочая схема из проекта: coercion

import * as z from "zod";
function coercionSchema() {
  return z.strictObject({ count: z.string().trim().min(1, "\u041F\u0443\u0441\u0442\u0430\u044F \u0441\u0442\u0440\u043E\u043A\u0430 \u043D\u0435 \u044F\u0432\u043B\u044F\u0435\u0442\u0441\u044F \u043A\u043E\u043B\u0438\u0447\u0435\u0441\u0442\u0432\u043E\u043C.").regex(/^\d+$/, "\u041A\u043E\u043B\u0438\u0447\u0435\u0441\u0442\u0432\u043E \u0434\u043E\u043B\u0436\u043D\u043E \u0441\u043E\u0441\u0442\u043E\u044F\u0442\u044C \u0438\u0437 \u0446\u0438\u0444\u0440.").transform(Number).pipe(z.number().int().min(0).max(1e3)), enabled: z.stringbool({ truthy: ["true", "1", "yes"], falsy: ["false", "0", "no"] }) });
}
const Schema = coercionSchema();
const result = await Schema.safeParseAsync(input);
if (result.success) console.log(result.data);
else console.log(result.error.issues);

У нашего количества правило явное: требуется непустая строка цифр, затем выполняется Number, затем проверяются целочисленность и диапазон. Пустое значение остаётся ошибкой, а строка "0" допустима. Для флага перечислены понятные строковые значения через stringbool. Такой контракт можно объяснить пользователю и проверить тестом.

Мини-мастерская дополнительно показывает результат обычного приведения JavaScript. Введите пустое количество и false в поле флага: сравнение демонстрирует, почему нельзя считать преобразование и проверку одним действием. Если бизнес допускает дроби, отрицательные значения или локальную запятую, определите эти правила отдельно: текущая схема намеренно описывает целое неотрицательное количество.

Документация: Преобразования примитивных значений в Zod.

Пример 5. Асинхронная проверка бизнес-правила

Некоторые условия невозможно проверить только по форме значения. Логин может быть синтаксически правильным, но уже занятым. Код склада может соответствовать регулярному выражению, но отсутствовать в справочнике. Для таких случаев refine может выполнять асинхронную функцию. В мастерской проверка ждёт небольшой интервал и сверяет имя с фиксированным учебным списком.

Рабочая схема из проекта: async

import * as z from "zod";
function asyncSchema(checkName = async (name) => {
  await new Promise((resolve) => setTimeout(resolve, 250));
  return !["admin", "support", "nekrasov"].includes(name);
}) {
  return z.strictObject({ username: z.string().trim().toLowerCase().regex(/^[a-z][a-z0-9_-]{2,23}$/, "\u041B\u043E\u0433\u0438\u043D: 3\u201324 \u0441\u0438\u043C\u0432\u043E\u043B\u0430, \u043B\u0430\u0442\u0438\u043D\u0438\u0446\u0430, \u0446\u0438\u0444\u0440\u044B, _ \u0438 -.").refine(async (name) => await checkName(name), { error: "\u042D\u0442\u043E\u0442 \u0443\u0447\u0435\u0431\u043D\u044B\u0439 \u043B\u043E\u0433\u0438\u043D \u0443\u0436\u0435 \u0437\u0430\u043D\u044F\u0442." }) });
}
const Schema = asyncSchema();
const result = await Schema.safeParseAsync(input);
if (result.success) console.log(result.data);
else console.log(result.error.issues);

Попробуйте admin, support или nekrasov — получите отказ. Затем введите другое корректное имя. Библиотека действительно ожидает Promise, но к базе сайта не обращается. В реальном проекте функция checkName может вызывать репозиторий или внутренний сервис. Важно ограничить длительность такого запроса и отделить «значение не подходит» от «сервис проверки недоступен».

Предварительная проверка уникальности не защищает от гонки. Два запроса могут одновременно увидеть свободный логин. Окончательную гарантию даёт уникальное ограничение в базе и корректная обработка конфликта записи. Кроме того, не стоит обращаться к БД для каждого символа без управления частотой и отменой устаревшего результата. В нашей мастерской результат старого запуска не подменяет новый выбранный сценарий.

Пример 6. Вложенный заказ и адреса ошибок

В корзине или длинной анкете одной пары «поле — сообщение» уже мало. Есть customer.address.zip, несколько строк items и отдельное количество у каждой. Zod возвращает путь ошибки массивом сегментов. Числовой сегмент указывает индекс элемента: например, ошибка количества первого товара относится к ["items", 0, "quantity"].

Рабочая схема из проекта: nested

import * as z from "zod";
function nestedSchema() {
  return z.strictObject({ customer: z.strictObject({ name: z.string().trim().min(2), address: z.strictObject({ city: z.string().trim().min(2), zip: z.string().regex(/^\d{6}$/, "\u0418\u043D\u0434\u0435\u043A\u0441: \u0440\u043E\u0432\u043D\u043E 6 \u0446\u0438\u0444\u0440.") }) }), items: z.array(z.strictObject({ sku: z.string().min(2).max(30), quantity: z.number().int().min(1).max(100) })).min(1).max(5) });
}
const Schema = nestedSchema();
const result = await Schema.safeParseAsync(input);
if (result.success) console.log(result.data);
else console.log(result.error.issues);

Вкладка дерева использует z.treeifyError. В ней видны properties для объектов и items для массивов. Это помогает связать ответ с правильным вложенным контролом. Для простой плоской формы удобно z.flattenError, но применять плоскую структуру как универсальное решение для сложного заказа не стоит: глубина важна для адресной подсветки.

Не связывайте серверный путь только с отображаемым номером строки, если пользователь может сортировать товары. Между отправкой и ответом порядок формы способен измениться. Практическое решение — фиксировать отправленный снимок или использовать стабильные идентификаторы строк в логике интерфейса. Схема сообщает, что было неверно в проверенном объекте, а сопоставление с текущим экраном остаётся задачей приложения.

Документация: Дерево, плоская структура и форматирование ошибок.

Пример 7. Разные варианты одной команды

Допустим, форма создаёт уведомление по email или в Telegram. Если сделать все поля optional, получится множество бессмысленных комбинаций: канал email без адреса, Telegram с темой письма, пустой объект. Discriminated union описывает отдельные полноценные варианты. Значение channel выбирает нужную ветку, а у ветки есть свой набор обязательных полей.

Рабочая схема из проекта: union

import * as z from "zod";
function unionSchema() {
  return z.discriminatedUnion("channel", [z.strictObject({ channel: z.literal("email"), email: z.email(), subject: z.string().trim().min(3).max(80) }), z.strictObject({ channel: z.literal("telegram"), chatId: z.string().regex(/^-?\d+$/, "chatId \u0434\u043E\u043B\u0436\u0435\u043D \u0431\u044B\u0442\u044C \u0441\u0442\u0440\u043E\u043A\u043E\u0439 \u0446\u0438\u0444\u0440, \u0432\u043E\u0437\u043C\u043E\u0436\u043D\u043E \u0441 \u043C\u0438\u043D\u0443\u0441\u043E\u043C."), text: z.string().trim().min(1).max(200) })]);
}
const Schema = unionSchema();
const result = await Schema.safeParseAsync(input);
if (result.success) console.log(result.data);
else console.log(result.error.issues);

В корректном примере выбрана email-ветка. Для Telegram замените объект на {"channel":"telegram","chatId":"-100123","text":"Задача готова"}. Смешивание полей двух вариантов даст ошибку: каждая ветка в нашем контракте строгая. chatId хранится строкой, поэтому учебный пример не теряет точность при преобразовании большого идентификатора в Number.

Такой подход применим не только к уведомлениям. Можно описать способы оплаты, операции импорта, разные задачи очереди или события аналитики. Важно выбирать поле, которое действительно определяет структуру. Проверка формы команды не означает, что отправитель имеет право выполнить её: доступ к каналу, получателю и конкретной операции проверяется отдельно.

Пример 8. Query string, пагинация и настоящий HTTP API

Параметры URL приходят строками и не должны напрямую превращаться в параметры запроса к базе. Клиент может попросить отрицательную страницу, миллион записей или неизвестную сортировку. В нашей схеме page ограничена, limit не превышает 100, sort выбирается из двух значений, а лишние параметры отклоняются. После разбора обработчик получает числа и понятные допустимые варианты.

Рабочая схема из проекта: api

import * as z from "zod";
function apiSchema() {
  return z.strictObject({ page: z.string().regex(/^\d+$/).transform(Number).pipe(z.number().int().min(1).max(1e3)).prefault("1"), limit: z.string().regex(/^\d+$/).transform(Number).pipe(z.number().int().min(1).max(100)).prefault("20"), sort: z.enum(["newest", "price"]).default("newest"), q: z.string().trim().max(80).optional() });
}
const Schema = apiSchema();
const result = await Schema.safeParseAsync(input);
if (result.success) console.log(result.data);
else console.log(result.error.issues);

Если page или limit отсутствуют, используется prefault: строковое значение по умолчанию проходит через обычную цепочку преобразования и становится числом. Это отличается от default, который в Zod 4 может сразу вернуть готовое выходное значение. Для трансформирующей схемы важно понимать, в каком месте цепочки появляется запасное значение.

curl -i 'http://127.0.0.1:3097/api/search?page=1&limit=3&sort=price'
# HTTP 200: filters с числами и три учебных элемента.

curl -i 'http://127.0.0.1:3097/api/search?limit=10000'
# HTTP 422: значение limit не прошло схему.

curl -i 'http://127.0.0.1:3097/api/search?page=1&page=2'
# HTTP 400: неоднозначные повторяющиеся параметры запрещены.

Каталог в проекте содержит 45 учебных элементов. Можно изменить поиск, сортировку и размер страницы, после чего увидеть реальную выдачу по HTTP. Сервер самостоятельно разбирает query string и применяет ту же схему, которую использует мастерская. Проверка формата сортировки не строит SQL: если подключите базу, сопоставляйте разрешённые варианты с заранее известными выражениями запроса.

Серверная проверка: браузеру нельзя доверять контракт

Валидация на клиенте нужна для удобства: человек получает быстрый отклик и не отправляет заведомо плохую форму. Но любой клиентский код можно обойти прямым HTTP-запросом. Поэтому сервер проверяет данные заново перед записью, отправкой сообщения или запуском задачи. Общий модуль схем помогает не разойтись в правилах, однако не отменяет серверную проверку.

В архиве есть три прикладных маршрута. POST /api/validate проверяет выбранный сценарий. POST /api/requests принимает форму и создаёт учебную запись. GET /api/search возвращает каталог с проверенной пагинацией. GET /api/requests показывает последние двадцать записей в памяти текущего процесса.

curl -i http://127.0.0.1:3097/api/requests   -H 'Content-Type: application/json'   --data '{"name":"Анна","email":"anna@example.com","message":"Нужна интеграция CRM.","consent":true}'
# HTTP 201: нормализованная запись с id и createdAt.

curl -i http://127.0.0.1:3097/api/requests   -H 'Content-Type: application/json'   --data '{"name":"А","email":"нет","message":"мало","consent":false}'
# HTTP 422: issues для полей формы.

Для учебного API установлен общий лимит 60 обращений к /api/ за минуту на весь процесс. Превышение возвращает 429 и Retry-After. Это простое ограничение стенда: в продукте понадобятся подходящие квоты по пользователю или клиенту и корректная схема доверенных прокси.

До Zod сервер ограничивает тело запроса 32 КБ и проверяет Content-Type. Сломанный JSON получает 400, неподдерживаемый формат — 415, превышение лимита — 413. Это разные ошибки на разных этапах. Ограничение размера нужно до построения большой структуры в памяти: схема массива сама по себе не ограничивает объём входящего HTTP-потока.

В проекте нет аккаунтов и постоянной базы. Заявки общие для учебного процесса, исчезают при перезапуске и никому не отправляются. Для реальной CRM добавьте авторизацию, права на чтение записей, постоянное хранилище и ограничения частоты запросов. Zod проверяет контракт данных, а не принадлежность заказа пользователю.

optional, nullable, default и prefault без неожиданностей

Отсутствующее значение, null и пустая строка — три разные ситуации. Поле может вообще не прийти, быть явно очищено клиентом или содержать пустой текст. Если смешать эти состояния, частичное обновление профиля способно случайно стереть сохранённые данные. Поэтому правила необязательных полей стоит обсуждать вместе с поведением API, а не добавлять optional везде ради прохождения теста.

const OptionalName = z.string().optional(); // Строка или undefined.
const NullableName = z.string().nullable(); // Строка или null.
const NullishName = z.string().nullish();   // Строка, null или undefined.

const Limit = z.string().regex(/^\d+$/)
  .transform(Number)
  .pipe(z.number().int().min(1).max(100));

Limit.default(20).parse(undefined);    // Готовый выход: число 20.
Limit.prefault('20').parse(undefined); // Вход '20' проходит разбор.

Пустая строка не становится undefined от использования optional. Если ваша форма отправляет пустой текст, определите отдельное правило преобразования или вообще не включайте поле в объект. В PATCH-запросе удобно разделять «не менять», «очистить» и «установить». Тогда клиент, схема и обработчик одинаково понимают намерение пользователя.

Вывод типов и повторное использование схем

В TypeScript тип можно получить из самой схемы. z.input описывает вход, z.output — результат после преобразований, z.infer соответствует выводимому выходному типу. Для page это существенно: до разбора передаётся строка, после разбора обработчик получает число. Если везде назвать тип просто Page, различие легко потерять.

import * as z from 'zod';
const Page = z.string().regex(/^\d+$/).transform(Number).pipe(z.number().int().min(1));
type PageInput = z.input<typeof Page>; // string
type PageOutput = z.output<typeof Page>; // number
const input: PageInput = '2';
const output: PageOutput = Page.parse(input);
const Contact = z.object({ name: z.string().min(2), email: z.email() });
type ContactData = z.infer<typeof Contact>;
const contact: ContactData = Contact.parse({ name: 'Анна', email: 'demo@example.com' });
console.log(output, contact);

Схемы удобно собирать из небольших частей: адрес, контакт, позиция заказа. Затем их можно использовать в разных командах. pick и omit помогают выбрать поля, partial делает поля необязательными на текущем уровне, extend добавляет новые. Однако схема публичной регистрации и схема административного изменения пользователя не обязаны совпадать. Не переносите служебные поля в клиентский контракт только ради экономии строк.

const Profile = z.object({
  name: z.string().trim().min(2),
  email: z.email(),
  role: z.enum(['reader', 'editor'])
});
const PublicSignup = Profile.omit({ role: true });
const ProfilePatch = Profile.pick({ name: true }).partial();

При изменении общего контракта проверяйте обе стороны. Более строгая серверная схема может начать отвергать старый клиент, а новое преобразование изменить выходной тип. Храните хорошие и плохие примеры рядом со схемой: они объясняют намерение лучше абстрактного списка методов.

Сообщения, локализация и безопасный вывод ошибок

Пользовательское сообщение должно описывать действие: «Укажите имя: минимум 2 символа» полезнее внутреннего кода. Разработчику одновременно нужны path и code, чтобы связать проблему с полем и диагностировать контракт. Не превращайте локализованный текст в машинный идентификатор: формулировку можно изменить, а логика клиента не должна от этого ломаться.

В мастерской подключена русская локаль и собственные сообщения для важных правил. Ошибки вставляются в DOM как текст. Это сохраняет границу между данными и HTML: значение, присланное клиентом, не должно превращаться в разметку страницы. В журнале API сохраняются метод, путь без query string, статус и длительность, но не содержимое заявки.

z.config(z.locales.ru());
const Email = z.email({ error: 'Укажите корректный email.' });

const parsed = Email.safeParse('не-почта');
if (!parsed.success) {
  messageElement.textContent = parsed.error.issues[0].message;
}

Если добавите пароль, токен или платёжные данные, не возвращайте исходный ввод в диагностическом ответе и не копируйте весь объект в логи. Для команды поддержки достаточно идентификатора запроса и ограниченного набора технических признаков. Наличие валидатора не делает автоматическое журналирование любых данных безопасным.

Документация: Настройка ошибок и локализация.

JSON Schema, документация и пределы преобразования

Из подходящей Zod-схемы можно получить JSON Schema. Это полезно для описания контракта другим инструментам, генерации документации и согласования формата JSON. Но произвольная JavaScript-функция transform или асинхронная проверка справочника не превращается автоматически в переносимое правило JSON Schema.

const PublicProduct = z.object({
  title: z.string().min(1).max(80),
  price: z.number().nonnegative()
});
const jsonSchema = z.toJSONSchema(PublicProduct);
console.log(JSON.stringify(jsonSchema, null, 2));

Перед экспортом решите, описываете ли вход до преобразования или нормализованный выход. Не подавляйте неподдерживаемые конструкции без понимания последствий: формально созданная документация может потерять важное ограничение. Практически удобно держать простую переносимую структуру данных отдельно от операций, требующих базы, сети и контекста пользователя.

Документация: Экспорт в JSON Schema и ограничения.

Размещение Node.js API на Linux VPS или выделенном сервере

Саму Zod отдельно на сервер не устанавливают как службу. Размещают приложение, которое использует библиотеку. Здесь это наш Node.js API. Схема для VPS простая: systemd запускает процесс от отдельного пользователя, Node слушает 127.0.0.1:3097, Caddy принимает домен по HTTPS и передаёт запросы внутрь. Порт приложения не нужно открывать наружу.

В README приведена полная установка официального Node.js 24.22.2 для Debian/Ubuntu с systemd, отдельно в /opt/node24, без замены системного Node. Архитектура выбирается между x64 и arm64; скачанный архив проверяется по SHA-256 из официального списка. Актуальную сборку ветки 24 перед длительным размещением стоит сверить на сайте Node.js.

set -euo pipefail
sudo apt-get update
sudo apt-get install -y curl ca-certificates xz-utils unzip
NODE_VERSION=24.22.2
case "$(uname -m)" in
  x86_64) NODE_ARCH=x64 ;;
  aarch64) NODE_ARCH=arm64 ;;
  *) echo 'Выберите сборку Node.js для вашей архитектуры'; exit 1 ;;
esac
NODE_FILE="node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz"
NODE_STAGE="$(mktemp -d)"
cd "$NODE_STAGE"
curl -fSLO "https://nodejs.org/dist/v${NODE_VERSION}/${NODE_FILE}"
curl -fSLO "https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt"
grep " ${NODE_FILE}$" SHASUMS256.txt | sha256sum -c -
sudo install -d -m 755 /opt/node24
sudo tar -xJf "$NODE_FILE" --strip-components=1 -C /opt/node24
/opt/node24/bin/node --version

Для отдельного нового сервиса создайте системного пользователя и папку приложения. Пример ниже предполагает, что имя zoddemo ещё не используется. При повторной установке существующего проекта не создавайте его заново. Команды сборки и тестирования выполняются от служебного пользователя, процесс также работает без root.

APP_STAGE="$(mktemp -d)"
cd "$APP_STAGE"
curl -fSL https://ae-nekrasov.ru/wp-content/plugins/aen-zod-workshop/zod-project.zip -o zod-project.zip
unzip -q zod-project.zip
sudo useradd --system --user-group --home-dir /opt/zod-demo --shell /usr/sbin/nologin zoddemo
sudo install -d -o zoddemo -g zoddemo /opt/zod-demo
sudo cp -a zod-project/. /opt/zod-demo/
sudo chown -R zoddemo:zoddemo /opt/zod-demo
cd /opt/zod-demo
sudo -u zoddemo env PATH=/opt/node24/bin:/usr/bin:/bin npm ci
sudo -u zoddemo env PATH=/opt/node24/bin:/usr/bin:/bin npm run build
sudo -u zoddemo env PATH=/opt/node24/bin:/usr/bin:/bin npm test
sudo -u zoddemo env PATH=/opt/node24/bin:/usr/bin:/bin npm run typecheck
sudo install -m 600 deploy/zod-demo.env.example /etc/zod-demo.env
sudo install -m 644 deploy/zod-demo.service /etc/systemd/system/zod-demo.service
sudo systemctl daemon-reload
sudo systemctl enable --now zod-demo
curl --fail http://127.0.0.1:3097/health

Файл окружения содержит только NODE_ENV, HOST и PORT. Если позже появятся секреты, ограничьте права файла до 600: systemd читает EnvironmentFile перед запуском процесса. Не кладите производственный файл окружения в публичную директорию или архив исходников. Unit из проекта задаёт автоматический перезапуск после ошибки, отдельного пользователя и ограничения доступа к файловой системе.

Готовый systemd unit из архива

[Unit]
Description=Zod validation workshop demo API
After=network.target

[Service]
Type=simple
User=zoddemo
Group=zoddemo
WorkingDirectory=/opt/zod-demo
EnvironmentFile=/etc/zod-demo.env
ExecStart=/opt/node24/bin/node src/server.mjs
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
UMask=0077

[Install]
WantedBy=multi-user.target

Если Caddy ещё нет, установите его из официального репозитория для Debian/Ubuntu. На новом сервере порты 80/443 должны быть свободны. Если веб-сервер уже установлен, сначала выберите схему проксирования, чтобы не создавать конфликт портов.

sudo apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg
curl -fsSL https://dl.cloudsmith.io/public/caddy/stable/gpg.key -o /tmp/caddy-stable-key.asc
sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg /tmp/caddy-stable-key.asc
curl -fsSL https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt -o /tmp/caddy-stable.list
sudo install -m 644 /tmp/caddy-stable.list /etc/apt/sources.list.d/caddy-stable.list
sudo chmod 644 /usr/share/keyrings/caddy-stable-archive-keyring.gpg
sudo apt-get update
sudo apt-get install -y caddy

Документация: Установка Caddy из официального репозитория.

У нашего учебного API общий список записей, поэтому стенд на домене закрываем паролем. Выполните caddy hash-password: команда интерактивно запросит пароль и напечатает хеш. Вставьте хеш целиком вместо REPLACE_WITH_YOUR_PASSWORD_HASH. Пароль не нужно писать в аргументе команды или сохранять в shell history. Basic Auth используется поверх HTTPS; это доступ к демо, а не замена полноценной модели прав приложения.

Добавьте отдельный блок своего домена в существующий Caddyfile. Не заменяйте конфигурацию других сайтов. DNS должен указывать на сервер, а необходимые порты — быть доступны. Если 80/443 уже обслуживает Nginx, настройте проксирование в нём либо заранее выберите общую схему. Два процесса не могут одновременно занять один и тот же IP:порт.

zod.example.com {
    basic_auth {
        demo REPLACE_WITH_YOUR_PASSWORD_HASH
    }
    encode zstd gzip
    reverse_proxy 127.0.0.1:3097
    log
}
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
# Замените ВАШ-ДОМЕН на настроенное имя.
curl --fail --user demo https://ВАШ-ДОМЕН/health
curl -i --user demo 'https://ВАШ-ДОМЕН/api/search?limit=3'
curl -i --user demo 'https://ВАШ-ДОМЕН/api/search?limit=10000'
# Ожидаем 200 и 422 соответственно.
sudo systemctl status zod-demo --no-pager
sudo journalctl -u zod-demo -n 30 --no-pager

После сохранения через sudoedit /etc/caddy/Caddyfile проверьте и примените конфигурацию. curl с --user demo запросит пароль в терминале; без учётных данных защищённый домен вернёт 401.

Проверка health подтверждает, что процесс отвечает, но не заменяет проверку прикладных маршрутов. Поэтому после размещения выполните и успешный, и ошибочный запрос. Если публичный адрес не работает, сравните прямой loopback-ответ и ответ через домен: так легче отделить проблему приложения от DNS, TLS и прокси. Подробности этих слоёв разобраны в статье о Caddy, HTTPS и reverse proxy.

Документация: Официальные сборки Node.js 24.

Логи, перезапуск и обновление приложения

При изменении переменных окружения выполните sudo systemctl restart zod-demo, затем проверьте health и журнал. Для обновления сначала распакуйте новую версию в отдельную папку, установите зависимости и выполните build, test, typecheck. Это позволяет обнаружить ошибку до замены работающей копии. В README приведён порядок обновления с сохранением предыдущей папки.

# Новая копия заранее подготовлена и проверена в /opt/zod-demo-next.
sudo systemctl stop zod-demo
sudo mv /opt/zod-demo "/opt/zod-demo-backup-$(date +%Y%m%d-%H%M%S)"
sudo mv /opt/zod-demo-next /opt/zod-demo
sudo chown -R zoddemo:zoddemo /opt/zod-demo
sudo systemctl start zod-demo
curl --fail http://127.0.0.1:3097/health
sudo journalctl -u zod-demo -n 30 --no-pager

Это понятный вариант с коротким перерывом, а не обновление без остановки. Для отката остановите сервис, сохраните неудачную новую копию под другим именем, верните предыдущую папку и запустите процесс. Учебные заявки из памяти при остановке теряются независимо от отката: постоянное хранение в этом стенде намеренно не реализовано.

Перед использованием API для реального продукта определите аутентификацию, права на данные, ограничения частоты обращений и поведение при недоступности зависимостей. Лимит тела и проверка схемы закрывают конкретные проблемы, но не являются полной защитой публичного сервиса. Для фоновых проверок и обработки тяжёлых операций можно вынести работу в отдельный worker, оставив в API быстрое принятие валидной команды.

Windows: запустить локально и проверить запросы

Для локальной игры на Windows установите Node.js 24 из официального дистрибутива, распакуйте архив и откройте PowerShell именно внутри zod-project. В командах указан npm.cmd, чтобы запуск не зависел от политики выполнения PowerShell-скриптов. Никакая системная служба Windows автоматически не создаётся.

node --version
npm.cmd ci
npm.cmd run build
npm.cmd test
npm.cmd run typecheck
npm.cmd start

Откройте тот же адрес http://127.0.0.1:3097. Во втором окне PowerShell выполните npm.cmd run smoke. Для проверки настоящей формы отправьте JSON через Invoke-RestMethod. Явное преобразование в UTF-8 помогает корректно передать русские значения независимо от локальной кодировки терминала.

$payload = @{
  name='Анна'
  email='anna@example.com'
  message='Нужна интеграция CRM.'
  consent=$true
} | ConvertTo-Json

Invoke-RestMethod http://127.0.0.1:3097/api/requests `
  -Method Post `
  -ContentType 'application/json; charset=utf-8' `
  -Body ([System.Text.Encoding]::UTF8.GetBytes($payload))

Invoke-RestMethod 'http://127.0.0.1:3097/api/search?limit=3'

Если порт занят, задайте $env:PORT="3098" перед запуском и откройте соответствующий адрес. Для smoke установите $env:ZOD_BASE_URL="http://127.0.0.1:3098". Завершить процесс можно Ctrl+C. Локальная проверка удобна для экспериментов; размещение с доменом, TLS и автозапуском выполняется по Linux-разделу выше.

Что проверяется в исходниках и как продолжить

Тесты проекта проверяют хорошие и плохие входы всех восьми сценариев, нормализацию без изменения исходного объекта, удаление и отклонение неизвестных ключей, пустую числовую строку, числовые значения по умолчанию, вложенные пути и ожидание асинхронного правила. Отдельно поднимается настоящий HTTP-сервер на свободном локальном порту и проверяются статусы API, лимит тела, неоднозначные query-параметры и отсутствие доступа к серверным исходникам через static-маршруты.

При адаптации начните со своих входящих данных. Возьмите реальный пример формы или ответа интеграции, удалите секреты, опишите обязательные поля и добавьте несколько плохих примеров. Затем решите, какие преобразования допустимы, что делать с неизвестными ключами и где показывать ошибки. После этого подключайте бизнес-операцию к проверенному результату.

Для большой системы полезно различать проверку формы, транспортный контракт и бизнес-правило. Например, допустимый формат номера заказа не доказывает его существование, существующий заказ не обязательно принадлежит текущему пользователю, а корректная сумма не подтверждает возможность списания. Zod удобно использовать для первой части и некоторых явных правил, сохраняя остальные проверки в соответствующих слоях приложения.

Документация и полезные материалы