Zod-valid — это Typescript библиотека, зависимая от другой известной библиотеки zod, для безопасной валидации API данных. API редко гарантирует идеальные данные: поля могут быть пропущены, типы не совпадать, структуры меняться. Без проверки этих данных приложение рискует вызвать runtime-ошибки или ломать бизнес-логику. Валидировать данные заранее — значит обеспечить предсказуемое поведение и защитить приложение от неожиданных значений.

Но давайте сначала рассмотрим использование стандартных инструментов библиотеки zod. Например, выдаёт ошибку, если данные не соответствуют схеме. И также необходимо получить правильный тип, чтобы с ним можно было работать в дальнейшем. Рассмотрим пример когда обычный zod может выкинуть ошибку:

import { z } from "zod";

const responseSchema = z.object({
  users: z.array(
    z.object({
      id: z.number(),
      first_name: z.string(),
      age: z.number(),
      address: z.object({
        country: z.string(),
        city: z.string(),
      }),
      skills: z.array(
        z.object({
          id: z.number(),
          name: z.string(),
          rating: z.number(),
        })
      ),
    }),
  ),
});

const response = await fetch("/api/users");
const rawData = await response.json();
const data = responseSchema.parse(rawData);

Или же рассмотрим ситуацию, когда используют "мягкий" парсинг - safeParse. Минус такого решения в том, что мы могли бы показать хоть какие-то допустимые в рамках типа данные, вместо того, чтобы "выбрасывать" ошибку, если это, конечно, допустимо в рамках задачи.

const data = responseSchema.safeParse(rawData);

if (data.error) {
  throw new Error("Invalid server data");
} 

Любое из свойств объекта может отсутствовать или вместо числа прийти строка и наоборот, поэтому мы для успешной валидации применяем coerce (специальный модификатор, который приводит входное значение к нужному типу, прежде чем применить валидацию) и метод nullish для успешной валидации, если на выходе undefined или null (используют там, где это допустимо), а также метод catch, который позволяет задать fallback-значение, если при парсинге поле не прошло валидацию. В итоге у нас получается следующая схема:

const responseSchema = z.object({
  users: z.array(
    z.object({
      id: z.coerce.number(),
      first_name: z.coerce.string().nullish().catch(null),
      address: z.object({
        country: z.coerce.string().nullish().catch(null),
        city: z.coerce.string().nullish().catch(null),
      }).nullish().catch(null),
      skills: z.array(
        z.object({
          id: z.coerce.number(),
          name: z.coerce.string().nullish().catch(null),
          rating: z.coerce.number().nullish().catch(null),
        }).nullish().catch(null)
      ).nullish().catch([]),
    }),
  ).catch([]),
}).nullish().catch(null);

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

// Argument of type 'string' is not assignable to parameter
// of type '(ctx: $ZodCatchCtx) => number'.

const age = z.number().catch("N/A");

Поэтому нам необходимо через метод or или union добавить возможность принимать и строки:

const rating = z.number().or(z.string()).catch("N/A");

// или

const rating = z.union([z.number(), z.string()]).catch("N/A");

Очень часто с сервера приходят массивы объектов, например, придумаем массив навыков (скиллов):

const skills = z.array(
  z.object({
    id: z.coerce.number(),
    name: z.coerce.string().nullish().catch(null),
    rating: z.coerce.number().or(z.string()).nullish().catch("N/A"),
  })
  .nullish()
  .catch(null)
)
.nullish()
.catch([]);

Тут в результате может получится так, что в массив попадут "грязные" значения типа null, поэтому нам надо отсечь их и оставить только валидные данные. Сделать это можем, например, через метод transform:

const skills = z.array(
  z.object({
    id: z.coerce.number(), 
    name: z.coerce.string().nullish().catch(null),
    rating: z.coerce.number().or(z.string()).nullish().catch("N/A"),
  })
  .nullish()
  .catch(null), // сработает, если придет некорректный id
)
.transform((arr) => arr.filter((item) => item))
.nullish()
.catch([]);

Но тут возникает проблема: тип у переменной skills не отражает результат метода transform. Хотя null и undefined мы исключили, на выходе все равно видим следующее:

type ResponseType = z.infer<typeof skills>;

/*
type Skills = ({
  id: number;
  name?: string | null | undefined;
  rating?: string | number | null | undefined;
} | null | undefined)[] | null | undefined
*/

После обработки данных стандартными методами zod у нас получается приблизительно следующая "гирлянда" из методов:

const responseSchema = z.object({
  id: z.coerce.number(),
  first_name: z.coerce.string().nullish().catch(null),
  address: z.object({
    country: z.coerce.string().nullish().catch(null),
    city: z.coerce.string().nullish().catch(null),
  })
  .nullish()
  .catch(null),
  skills: z.array(
    z.object({
      id: z.coerce.number(),
      name: z.coerce.string().nullish().catch(null),
      rating: z.coerce.number().or(z.string()).nullish().catch("N/A"),
    })
    .nullable()
    .catch(null),
  )
  .transform((arr) => arr.filter((item) => item))
  .nullish()
  .catch([]),
})
.nullish()
.catch(null);

type ResponseType = z.infer<typeof responseSchema>;

/*
type ResponseType = {
  id: number;
  first_name?: string | null | undefined;
  address?: {
    country?: string | null | undefined;
    city?: string | null | undefined;
  } | null | undefined;
  skills?: ({
    id: number;
    name?: string | null | undefined;
    rating?: string | number | null | undefined;
  } | null | undefined)[] | null | undefined;
} | null | undefined
*/

Чем больше повторяющихся конструкций — тем тяжелее воспринимать схему целиком: теряется структура, приходится глазами вычленять полезное среди "шума". Такой код сложно поддерживать. Поэтому в помощь приходит библиотека zod-valid, которая помогает убрать рутину и оставить на виду только суть — объект, его поля и вложенные массивы. Это повышает прозрачность кода и снижает вероятность ошибок при работе с реальными данными. Тот же самый объект, но уже с использованием zod-valid будет иметь следующий вид:

import {
  toValidString,
  toValidNumber,
  toValidArray,
  toValidObject,
} from 'zod-valid';

const responseSchema = toValidObject(z.object({
  id: toValidNumber({ allow: "none" }),
  first_name: toValidString(),
  address: toValidObject(z.object({
    country: toValidString(),
    city: toValidString(),
  })),
  skills: toValidArray(
    toValidObject(z.object({
      id: toValidNumber({ allow: "none" }),
      name: toValidString(),
      rating: toValidNumber({ fallback: "N/A" }),
    })),
  ),
}));

type ResponseType = z.infer<typeof responseSchema>;

/*
type ResponseType = {
  id: number;
  first_name?: string | null | undefined;
  address?: {
    country?: string | null | undefined;
    city?: string | null | undefined;
  } | null | undefined;
  skills?: {
    id: number;
    name?: string | null | undefined;
    rating?: string | number | null | undefined;
  }[] | null | undefined;
} | null | undefined
*/

Давайте теперь разберемся поподробнее. Установить вы можете с помощью команды:

npm install zod-valid zod

Zod-valid на данный момент имеет несколько методов:

  • toValidString - используется для работы со строками;

  • toValidNumber - аналогично работает с числами;

  • toValidBoolean - нормализует булевы значения;

  • toValidISO - позволяет валидировать и нормализовать дату/время в формате ISO;

  • toValidArray - работает с массивами;

  • toValidObject - используется для объектов, enum;

А также утилиту nonNullable - запрещает null и undefined, а также исключает их из типа и выдаёт кастомное сообщение об ошибке.

Все методы библиотеки zod-valid имеют объект с настройками и они являются не обязательными для заполнения:

  • type — схема zod, на основе которой строится валидация. Например, z.string()z.number() или z.object({...}). У некоторых методов есть значение по-умолчанию, и, например для toValidString мы вместо дефектного значения z.string() можем переопределять, например, на z.email().

  • allow — задаёт, какие «проблемные» значения всё ещё можно пропускать. Например: "none" позволяет не возвращать null/undefined, а просто заменить их на fallback.

  • fallback — значение по-умолчанию, которое будет подставлено, если входные данные невалидные.

  • preserve — если true, то данные не изменяются даже при ошибке валидации (валидатор просто вернёт их как есть). Если false — сработает fallback.

Метод toValidArray также имеет настройку strict, которая определяет, как обрабатывать элементы внутри массива. По-умолчанию настройка включёна (true), в итоговый массив попадут только корректные элементы, а невалидные будут удалены. Если выключена (false), то элементы сохраняются как есть.

Важно учесть, что тип можно вынести в первый параметр метода, а оставшиеся настройки передать во второй параметр. Например:

// метод без параметров (настройки по-умолчанию)
toValidString();

// метод с одним параметром типа
toValidString(z.email());

// метод с одним параметром настроек
toValidString({ type: z.email(), fallback: "N/A", preserve: false });

// метод с двумя параметрами
toValidString(z.email(), { fallback: "N/A", preserve: false });

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

toValidString

Работа со строками. Значение приводится к строке через String().

Параметр

Значение по-умолчанию

type

z.string()

fallback

null

allow

"nullish"

preserve

true

toValidNumber

Работа с числами. Преобразует значение в число и возвращает только конечные числа с помощью Number.isFinite.

Параметр

Значение по-умолчанию

type

z.number()

fallback

null

allow

"nullish"

preserve

true

toValidBoolean

Работа с булевыми значениями. Возвращает корректный булевый эквивалент строки, числа или объекта.

Параметр

Значение по-умолчанию

type

z.boolean()

fallback

null

allow

"nullish"

preserve

true

toValidIso

Работа с датами. Конвертирует строку в ISO‑дату, проверяя корректность.

Параметр

Значение по-умолчанию

type

z.iso.datetime()

fallback

null

allow

"nullish"

preserve

true

toValidObject

Работа с объектами, enum. Возвращает значение, если это plain object и оно соответствует правилам.

Параметр

Значение по-умолчанию

type

z.object({}).catchall(z.any())

fallback

null

allow

"nullish"

preserve

true

toValidArray

Возвращает валидированный массив, оставляя только элементы, соответствующие правилам, при строгой проверке. Здесь есть отличие от других методов в том, что fallback по-умолчанию не null, а пустой массив ([]), а также, что имеется дополнительная настройка strict, о ней мы писали выше.

Параметр

Значение по-умолчанию

type

z.array(z.never())

fallback

[]

allow

"nullish"

preserve

true

strict

true

Итоги

Zod-valid отлично сокращает время на написание zod-схем, позволяет расширять их без особого труда, помогает быстро вникнуть в суть схемы без необходимости распутывать "гирлянду" zod-методов.

Ссылка на GitHub.

Ссылка на npm.

А как вы валидируете ответы от сервера и какими инструментами пользуетесь?

Комментарии (0)