Всем привет! Меня зовут Алексей Гмитрон, я фулстек-разработчик и наставник на курсе «Фронтенд-разработчик» в Практикуме. Довольно долгое время я разрабатываю интерфейсы, а ещё дольше — пользуюсь ими. 

В этом году я много путешествовал, поэтому нередко заполнял формы с анкетами на разные визы — в них бывало по 30—40 полей. Когда что-то шло не так, часто сайты не давали никакой обратной связи. Иногда они сбрасывали всё, что я заполнял в течение часа, если одно из полей невалидно. 

Решить проблему могла бы валидация. Это критически важная часть разработки веб-приложений, которая соотносит данные с необходимым форматом и указывает на ошибки. Также она гарантирует безопасность дальнейшей обработки этих данных. 

В этой статье мы разберёмся, как настроить валидацию поля ввода.

Задача, которую мы будем решать

Есть некий интернет-магазин, где пользователи должны указать свой адрес, чтобы оформить заказ. Они случайно вводят некорректные или неполные данные, потому что у сайта отсутствует валидация поля для ввода почтового индекса.

Дело не в том, что индекс некорректен (например, указан чей-то чужой). Он невалиден, то есть не соответствует необходимому формату, и даже нельзя установить, чей он.

В зависимости от выбранной страны индекс может иметь разный формат — давайте рассмотрим несколько примеров.

Правильные (валидные) почтовые индексы:

  • США: 10001 (Нью-Йорк), 90210 (Беверли-Хиллз) — состоят из 5 цифр. 

  • Россия: 101000 (Москва), 190000 (Санкт-Петербург) — из 6 цифр. 

  • Великобритания: SW1A 1AA (Букингемский дворец, Лондон), M1 1AA (Манчестер) — из букв и цифр в определённой последовательности.

Неправильные (невалидные) почтовые индексы:

  • США: 1234, AB123 — первый слишком короткий и не соответствует стандарту из 5 цифр, а второй содержит буквы, что недопустимо для американских индексов.

  • Россия: 1234567, 12AB56 — первый слишком длинный, второй содержит буквы.

  • Великобритания: A1 1AA, 123 AB — первый нарушает стандартное строение британских индексов, второй не соответствует общепринятой структуре.

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

Отдел обработки заказов перегружен запросами на коррекцию адресов. Это увеличивает нагрузку на сотрудников и повышает операционные расходы. Некоторые клиенты могут отказаться от повторного заказа из-за плохого опыта взаимодействия с сайтом — это ведёт к снижению выручки.

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

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

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

Однако, когда код скомпилирован и запущен (runtime), TypeScript уже не может контролировать, какие данные передаются в функции или присваиваются переменным. Например, если функция ожидает число, TypeScript гарантирует, что код не будет компилироваться, если вы передадите в неё строку. Но он не может проверить, действительно ли число находится в ожидаемом диапазоне (например, положительное). 

Кроме того, данные, получаемые от пользователя через формы, от внешних API или из баз данных, могут быть непредсказуемыми и не соответствовать ожидаемым типам, определённым в TypeScript.

Таким образом, TypeScript является отличным инструментом для обеспечения типобезопасности на этапе разработки, но он не заменяет необходимость валидации данных во время выполнения программы. Всегда необходимо проверять корректность и безопасность входящих данных во время выполнения кода (runtime).

Дальше мы рассмотрим несколько вариантов, как это можно сделать.

Рабочий, но не лучший вариант: реализация кода «в лоб»

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

  • имя пользователя — не пустая строка;

  • возраст — обязательно число, строка вида «Мне 16» не подойдёт;

  • электронная почта — формат something@domain.zone;

  • пароль — не менее 8 символов, содержит минимум одну цифру, одну заглавную и одну строчную букву

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

function validateUserData(userData) {
  const errors = [];

  // Проверка имени
  if (typeof userData.name !== 'string' || userData.name.trim() === '') {
    errors.push('Имя не указано или некорректно');
  }

  // Проверка возраста
  if (typeof userData.age !== 'number' || userData.age < 18) {
    errors.push('Возраст должен быть числом не меньше 18');
  }

  // Проверка электронной почты
  if (typeof userData.email !== 'string' || !userData.email.includes('@')) {
    errors.push('Некорректный формат электронной почты');
  }

  // Проверка пароля
  const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$/;
  if (typeof userData.password !== 'string' || !passwordRegex.test(userData.password)) {
    errors.push('Пароль должен быть не менее 8 символов и содержать минимум одну цифру, одну заглавную и одну строчную букву');
  }

  return errors;
}

// Пример использования
const userData = {
  name: 'Иван',
  age: 20,
  email: 'ivan@example.com',
  password: 'Password123'
};

const validationErrors = validateUserData(userData);
if (validationErrors.length > 0) {
  console.log('Ошибки валидации:', validationErrors);
} else {
  console.log('Все данные корректны');
}

В этом примере функция validateUserData принимает объект userData и возвращает массив с сообщениями об ошибках. Для каждого поля выполняется серия if-else проверок, чтобы убедиться, что данные соответствуют определённым критериям.

Этот подход, хоть и работает, имеет несколько недостатков:

  • громоздкость — большое количество if-else усложняет чтение и понимание кода;

  • трудность поддержки — добавление новых правил валидации или изменение существующих может потребовать значительных изменений в коде;

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

Более удачный вариант: декларативный подход к валидации

Если посмотреть на код выше, скорее всего, возникнет мысль, что было бы круто описать тип в виде JS-объекта (для тех, кто знаком с TypeScript, может подойти в качестве примера интерфейс или структурный тип). Вот бы написать что-то такое: 

// Было бы классно иметь что-то такое в TypeScript
interface User {
   name: string;
   age: number;
   email: Email;
   password: Password;
}


// или что-нибудь такое в чистом JavaScript
const User = {
   name: String,
   age: Number,
   email: Email,
   password: Password
}

И жизнь была бы прекрасна без этих всех if-else…

К счастью, уже есть проверенное решение — библиотека Zod.

Zod — это библиотека валидации для JavaScript и TypeScript, предназначенная для создания схем и проверки данных. Она примечательна своей гибкостью и мощностью, позволяя разработчикам строго определять структуры данных и правила, которым эти данные должны соответствовать. Кроме того, Zod отлично выводит типы, что делает это мощнейшим инструментом в сочетании с TypeScript.

Основные особенности Zod:

  • типобезопасность — Zod эффективно работает с TypeScript, позволяя автоматически генерировать типы из ваших схем. Это обеспечивает дополнительный уровень безопасности при работе с данными, гарантируя, что они соответствуют ожидаемым структурам;

  • гибкость и расширяемость — библиотека предоставляет широкий набор встроенных валидаторов и позволяет легко создавать кастомные проверки; 

  • простота использования — Zod предлагает чистый и интуитивно понятный API, сокращая количество шаблонного кода.

Давайте перепишем наш пример кода на Zod:

import { z } from 'zod';

const passwordValidationRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$/;

const userSchema = z.object({
  name: z.string(),
  age: z.number().min(18),
  email: z.string().email(),
  password: z.string()
    .min(8, { message: "Пароль должен быть не менее 8 символов" })
    .refine((val) => passwordValidationRegex.test(val), {
      message: "Пароль должен содержать минимум одну цифру, одну заглавную и одну строчную букву",
    }),
});

Здесь создается схема валидации данных пользователя. Схема — это описание типа данных, который определяет, какие данные ожидаются и как они должны быть структурированы. В общем и целом очень похоже на TypeScript, однако он не может нам помочь во время выполнения кода, ведь он испаряется после компиляции и служит нам только в момент разработки.

Наша схема описывает поля следующим образом:

  • name: z.string() — поле name должно быть строкой;

  • age: z.number().min(18) — поле age должно быть числом, минимальное допустимое значение — 18;

  • email: z.string().email() — поле email должно быть строкой, соответствующей формату электронной почты.

Чуть интереснее валидация пароля: это строка с минимальным количеством символов 8, после которой вызывается функция refine. Эта функция позволяет задать свои собственные условия или правила, которым должны соответствовать данные. Она не ограничивается стандартными проверками, такими как проверка типа или длины строки. В нашем примере:

password: z.string()
  .min(8, { message: "Пароль должен быть не менее 8 символов" })
  .refine((val) => passwordValidationRegex.test(val), {
    message: "Пароль должен содержать минимум одну цифру, одну заглавную и одну строчную букву",
  }),

Метод refine переопределяет (о чём и говорит название метода) стандартное поведение строки: чтобы определить, как должно валидироваться конкретно это строчное поле, мы объявляем функцию, в которой делается проверка строки на соответствие регулярному выражению. Вместо регулярного выражения могла быть и любая другая функция с иными проверками — главное, чтобы функция возвращала true или false.

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

userSchema.parse({
      name,
      age,
      email,
      password
    });

Теперь давайте напишем небольшой код, который будет валидировать поля по нажатию кнопки.

HTML:

<html>
 <head>
   <title>Validation</title>
   <meta charset="UTF-8" />
 </head>


 <body>
   <div id="app"></div>


   <input type="text" id="name" placeholder="name" />
   <input type="text" id="age" placeholder="age" />
   <input type="text" id="email" placeholder="email" />
   <input type="text" id="password" placeholder="password" />
   <button id="validateButton">Validate</button>
   <span id="validationErrors"></span>


   <script src="src/index.ts"></script>
 </body>
</html>

JavaScript

import { z } from "zod";


const passwordValidationRegex =
 /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$/;


const userSchema = z.object({
 name: z.string(),
 age: z.number().min(18),
 email: z.string().email(),
 password: z
   .string()
   .min(8, { message: "Пароль должен быть не менее 8 символов" })
   .refine((val) => passwordValidationRegex.test(val), {
     message:
       "Пароль должен содержать минимум одну цифру, одну заглавную и одну строчную букву",
   }),
});


document.getElementById("validateButton").addEventListener("click", () => {
 const name = document.getElementById("name").value;
 const age = parseInt(document.getElementById("age").value, 10);
 const email = document.getElementById("email").value;
 const password = document.getElementById("password").value;


 try {
   // Проверяем валидность данных
   userSchema.parse({
     name,
     age,
     email,
     password,
   });
   document.getElementById("validationErrors").textContent =
     "Все данные корректны";
 } catch (error) {
   // Если данные невалидны, то будет ошибка
   document.getElementById("validationErrors").textContent = error.errors
     .map((e) => e.message)
     .join(", ");
 }
});

Кода гораздо меньше, он куда более расширяемый и декларативный. Поддерживать его — сплошное удовольствие, чего не скажешь о подходе «в лоб», когда мы описываем всё через if-else.

Если бы мы валидировали «в лоб», типы для схемы пришлось бы писать отдельно. Это не проблема, когда в ней всего 4 поля, но на практике полей бывает сотни. Поддерживать отдельно функцию для валидации и параллельно добавлять поля в интерфейс довольно утомительно. Zod позволяет просто взять и вывести тип из нашей схемы, а затем использовать его везде, где нужно. 

Вывод типа с помощью утилиты z.infer и использование полученных данных:

import { z } from "zod";
const passwordValidationRegex =
 /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$/;


const userSchema = z.object({
 name: z.string(),
 age: z.number().min(18),
 email: z.string().email(),
 password: z
   .string()
   .min(8, { message: "Пароль должен быть не менее 8 символов" })
   .refine((val) => passwordValidationRegex.test(val), {
     message:
       "Пароль должен содержать минимум одну цифру, одну заглавную и одну строчную букву",
   }),
});


// вывели тип с помощью z.infer
// обратите внимание, что нам нужно взять тип объекта через typeof!
type UserType = z.infer<typeof userSchema>;


// Пример использования
function processUser(user: UserType) {
 // Теперь вы можете быть уверены, что 'user' соответствует вашей схеме
 console.log("Processing user:", user.name);
}


// Пример использования с валидными данными
try {
 const validUser = userSchema.parse({
   name: "Иван",
   age: 30,
   email: "ivan@example.com",
   password: "Password123",
 });
 processUser(validUser);
} catch (error) {
 console.error("Validation failed:", error);
}


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


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

Хорошая валидация делает жизнь проще как для разработчиков, так и для пользователей. Использование инструментов, таких как Zod, делает этот процесс ещё более гладким и приятным. 

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

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


  1. muradali
    05.01.2024 12:41
    +1

    Классная библиотека! Вижу тенденцию перехода именно на Zod сейчас везде, хотя раньше более популярна была Yup и другие в таком же стиле.


    Так же к статье можно добавить, что Zod прекрасно работает как на фронте так и на бэке в Next.js например, и прекрасно интегрируется с ReactHookForm. Комбинация всех составляющих позволяет фигачить любые формы с проверками входных данных просто как нефиг делать.

    Даю рецепт:
    1. Берешь в Next.js клиентском компоненте делаешь форму на React Hook Form.
    2. Делаешь схему Zod и вывод типа из нее.

    3. Подключаешь в RHF Zod в качестве валидатора.
    4. Отправляешь форму через server action на сервер одной строчкой кода.
    5. На бэке, используя ту же схему Zod, проверяешь данные формы и сохраняешь в базу.


  1. klopp_spb
    05.01.2024 12:41

    • электронная почта — формат something@domain.zone;

    • пароль — не менее 8 символов, содержит минимум одну цифру, одну заглавную и одну строчную букву

    Уж сколько раз твердили миру...


  1. yudeek
    05.01.2024 12:41

    interface User {

    name: string;
    age: number;
    email: Email;
    password: Password;
    }


    Подскажите, а каким образом вы из формы (input) получили вот такой объект с указанными типами?

    <input type="number"> // вам он тут вернул тип Number в input.value?


  1. VolshRus
    05.01.2024 12:41
    -2

    Печаль в том, что все вышесказанное относится только к новым проектам. Если брать практически любой существующий, то окажется, что в базе уже лежит N тысяч невалидных данных, которые всё-таки нужно показывать пользователю (в надежде, что он их распознает). И тогда все эти истории с типизированными классами идут лесом... а жаль.


  1. nin-jin
    05.01.2024 12:41
    +3

    Судя по бенчмаркам zod самый тормозной.