Zod — валидация и вывод типов на основании схемы данных
Tags: typescript, tutorial, zod, validation
Published: 2023-01-23 13:33:05

Обычно, когда речь заходит о валидации данных на стороне клиента, имеют в виду корректность заполнения пользователем полей в форме ввода. На проверку данных, прочитанных с сервера или подготовленных для отправки, мало кто тратит время. Да и зачем? Ведь разработчик бэка классный специалист и мы на прошлом митинге согласовали с ним структуру объекта, который он будет возвращать через REST запрос. Только жизнь часто вносит свои коррективы в эту идиллию.
 
Недавно пришлось делать ревью кода, где в одном из полей сервер передавал данные как объект, а фронт считал, что там должна быть строка. Проект при этом рабочий. Возможно данное поле уже утратило актуальность и нигде не используется. Или разработчик фронта сделал преобразование по месту, прямо перед рендером на экран. А может, скрытая ошибка еще ждет своего времени, чтобы появиться в самый ответственный момент презентации важному клиенту.

При этом бэк и фронт из моего примера используют типизированные языки (C# и Typescript), но как в том детском стишке: однако, за время пути, собака могла подрасти! Сетевого зазора, когда данные существуют в виде текста JSON, достаточно для возникновения разногласий. 

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

Прямолинейный подход предусматривает, что мы сначала определяем тип, а затем создаем функцию валидации, которая принимает на вход объект (обычно, после парсинга из JSON), проверяет его и приводит к этому типу.

Прелесть Typescript заключается в его способности конструировать тип данных (type inference) на основе действий над этим данными. Т.е. мы можем начать с написания функции валидации и воспользоваться типом данных, который получится на ее выходе.

Такой подход применяется в библиотеке [Zod](https://github.com/colinhacks/zod), пример использования которой мы покажем ниже. Ради простоты, чтобы не возиться с установкой зависимостей для запуска кода мы будем использовать [Deno](https://deno.land/). Исходники примеров из этой статьи можно взять [здесь](https://gist.github.com/gsbelarus/2c4647201773f2fe3e4012633a3bb26f).

Пусть некоторое клиентское корпоративное приложение получает данные о сотруднике предприятия, состоящие из идентификатора и полного имени. Оба — строковые поля.

Мы начинаем с определения схемы данных и получаем на выходе объект с функцией валидации:
```ts
import { z } from "https://deno.land/x/zod/mod.ts";

const Employee = z.object({
  id: z.string(),
  name: z.string()
});
```

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

```ts
const employee = Employee.parse({
  id: 'eceffe7a-bbe6-41e9-bdc3-09b2970a0d8c',
  name: 'James Bond'
});

console.log(employee);
// { id: "eceffe7a-bbe6-41e9-bdc3-09b2970a0d8c", name: "James Bond" }
```

попытка передать данные неверного типа: 

```ts
Employee.parse({
  id: 1234,
  name: 'James Bond'
});
```
приведет к законному исключению:
```js
error: Uncaught ZodError: [
  {
    "code": "invalid_type",
    "expected": "string",
    "received": "number",
    "path": [
      "id"
    ],
    "message": "Expected string, received number"
  }
]
```

Обратите внимание, насколько детально сообщает `zod` о выявленном несоответствии в данных.

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

Пока неплохо, но где же здесь тип? Zod позволяет нам получить его автоматически на основании схемы валидации:

```ts
type Employee = z.infer<typeof Employee>;
// type Employee = {
//    id: string;
//    name: string;
//}
```

Мы справились с проверкой на базовые типы, но в реальном мире данные подчиняются дополнительным ограничениям, накладываемым применяемыми технологиями (например, строковое поле в базе данных имеет свой предельный размер), предметной областью или бизнес-логикой предприятия.

Усложним задачу:

1. id — это идентификатор в формате UUID.
2. name — это имя и фамилия, имя может быть двойным, записано латинскими символами, первая буква заглавная, остальные малые, минимальная длина имени и фамилии 2 символа. Максимальная длина всей строки 60 символов.

```ts
const Employee = z.object({
  id: z.string().uuid(),
  name: z.string()
    .regex(/(([A-Z]{1}[a-z]{1,40})(\s[A-Z]{1}[a-z]{1,40})?)\s([A-Z]{1}[a-z]{1,40})/)
    .max(60)
});
```

Тип объекта при этом не изменится. Его поля по-прежнему просто строки. Прописанные выше правила применяются в момент проверки при вызове методов `parse` или `safeParse`.

`Zod` позволяет прописать сложные проверки на основании данных из нескольких полей объекта. Усложним наш объект, добавив следующие поля и правила:

1. Дата рождения. Может отсутствовать. Если указана, то должна быть в диапазоне от 1.01.1950 до 31.12.2100. Именно так! Проверка на верхнюю границу никогда не будет лишней. Мы однажды всей компанией чуть с ума не сошли от одной ошибки у нашего клиента. Данные на экране были корректны, но в отчет не попадали. Пока не догадались вывести четыре цифры для года. Оказалось, что каким-то чудом оператор ввел в поле даты не двухтысячный, а трехтысячный год.
2. Дата приема на работу. Если дата рождения присутствует, то дата приема должна быть не менее, чем дата рождения плюс шестнадцать лет (мы не используем труд несовершеннолетних!), но в любом случае в диапазоне от 2000 до 2100 года.
3. Дата увольнения. Если присутствует, то должна быть не ранее даты приема на работу.

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

```ts
const Employee = z.object({
  id: z.string().uuid(),
  name: z.string()
    .regex(/(([A-Z]{1}[a-z]{1,40})(\s[A-Z]{1}[a-z]{1,40})?)\s([A-Z]{1}[a-z]{1,40})/)
    .max(60),
  dob: z.date()
    .min(new Date('01-01-1950Z'))
    .min(new Date('31-12-2100Z'))
    .optional(),
  employmentDate: z.date()
    .min(new Date('01-01-2000Z'))
    .min(new Date('31-12-2100Z')),
  dismissalDate: z.date()
    .min(new Date('01-01-2000Z'))
    .min(new Date('31-12-2100Z'))
    .optional()
})
.refine( 
  obj => typeof(obj.dob) === 'undefined' || 
    (obj.employmentDate.getTime() - obj.dob.getTime()) > 16 * 365 * 24 * 60 * 60 * 1000,
    {
      message: 'Too young to work!'
    } 
  )
.refine( 
  obj => typeof(obj.dismissalDate) === 'undefined' || 
    (obj.dismissalDate.getTime() >= obj.employmentDate.getTime()),
    {
      message: 'Dismissal date is before employment!'
    } 
  );
```

Посмотрим на тип объекта после добавленных правил валидации:

```ts
type Employee = {
    dob?: Date | undefined;
    dismissalDate?: Date | undefined;
    id: string;
    name: string;
    employmentDate: Date;
}
```

Думаю к этому моменту основная идея библиотеки `zod` — вывод типа данных на основе правил валидации — и принципы построения таких правил уже понятны читателю. Для закрепления, покажем как `zod` справляется с перечислениями и коллекциями объектов.

Добавим следующие данные:

1. Уровень сотрудника. Опциональное поле. Перечисление из следующих элементов: J1, J2, J3, M1, M2, M3, S1, S2, S3. 
2. Набор навыков сотрудника. Опциональное поле. Задается непустым массивом объектов, каждый из которых содержит ИД навыка и его название. Название -- не пустая строка максимальной длиной 60 символов. Конкретный навык может встречаться в массиве только один раз. Максимальное количество навыков -- не более 100.

```ts
const Levels = ['J1', 'J2', 'J3', 'M1', 'M2', 'M3', 'S1', 'S2', 'S3'] as const;

const Skill = z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(60)
}).strict();

const Employee = z.object({
  id: z.string().uuid(),
  name: z.string()
    .regex(/(([A-Z]{1}[a-z]{1,40})(\s[A-Z]{1}[a-z]{1,40})?)\s([A-Z]{1}[a-z]{1,40})/)
    .max(60),
  dob: z.date()
    .min(new Date('01-01-1950Z'))
    .min(new Date('31-12-2100Z'))
    .optional(),
  employmentDate: z.date()
    .min(new Date('01-01-2000Z'))
    .min(new Date('31-12-2100Z')),
  dismissalDate: z.date()
    .min(new Date('01-01-2000Z'))
    .min(new Date('31-12-2100Z'))
    .optional(),
  level: z.enum(Levels).optional(),
  skills: Skill
    .array()
    .min(1)
    .max(100)  
    .refine( s => (new Set(s.map( i => i.name ))).size === s.length, 'Duplicate skills are not allowed!')
    .optional()
})
.refine( 
  obj => typeof(obj.dob) === 'undefined' || 
    (obj.employmentDate.getTime() - obj.dob.getTime()) > 16 * 365 * 24 * 60 * 60 * 1000,
    {
      message: 'Too young to work!'
    } 
  )
.refine( 
  obj => typeof(obj.dismissalDate) === 'undefined' || 
    (obj.dismissalDate.getTime() >= obj.employmentDate.getTime()),
    {
      message: 'Dismissal date is before employment!'
    } 
  )
.strict();
```

Обратите внимание, как мы пресекли возможность неучтенным полям просочиться в наш объект, добавив правило `strict` в схему валидации.

Кроме валидации, `zod` позволяет определить правила преобразования данных, которые особенно удобны в ситуации, когда спецификации бэка меняются по ходу разработки, а ресурсов и времени, на адаптацию фронта к этим изменениям нет. 

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

```ts
const Employee = z.object({
  id: z.string().uuid(),
  name: 
    z.object({
      firstName: z.string().regex(/(([A-Z]{1}[a-z]{1,40})(\s[A-Z]{1}[a-z]{1,40})?)/),
      lastName: z.string().regex(/[A-Z]{1}[a-z]{1,40}/)
    })
    .transform( ({firstName, lastName}) => `${firstName} ${lastName}` ),   
  ...
```

`zod` предоставляет и альтернативный вариант, когда данные могут быть *трансформированы* до этапа парсинга и валидации. Какой из двух подходов применять — дело вкуса и специфики конкретного приложения.

Для данных, которые следуют за объектно-ориентированной моделью приложения, `zod` предоставляет возможности:

1. Расширять схему родителя дополнительными полями и правилами потомка.
2. Сливать несколько схем в одну.
3. Создавать новую схему на основе части полей из существующей схемы (pick/omit), а также изменять свойства полей (partial/required).

Продолжая наш пример с сотрудником предприятия мы можем ввести тип данных для менеджера проектов, добавив поле с названием проекта:

```ts
const PM = Employee.extend({
  projectName: z.string()
});
```

Обратите внимание, что расширять схему, которая включает проверки и преобразования структур данных не получится. В этом случае родительскую схему следует разделить на чисто схему и схему с проверками.

Некоторые из возможностей библиотеки остались за скобками настоящей статьи (асинхронная валидация, рекурсия схем, создание схем для функций и др.). Библиотека `zod` активно развивается. Список самых свежих изменений и примеров применения можно найти в рабочем [репозитории](https://github.com/colinhacks/zod).