Валидация входных данных

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

Валидация в интернационализированном приложении обычно включает:

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

Наиболее часто FormatJS используется совместно с:

  • react-intl
  • intl-messageformat
  • @formatjs/intl
  • библиотеками форм (Formik, React Hook Form)
  • схемами валидации (Yup, Zod, Joi)

Локализация сообщений об ошибках

Базовая структура сообщений

Наиболее распространённый подход — хранение текстов ошибок в словаре локализации.

import { defineMessages } from 'react-intl';

export const validationMessages = defineMessages({
  required: {
    id: 'validation.required',
    defaultMessage: 'Поле обязательно'
  },

  invalidEmail: {
    id: 'validation.invalidEmail',
    defaultMessage: 'Некорректный email'
  },

  minLength: {
    id: 'validation.minLength',
    defaultMessage: 'Минимальная длина: {min}'
  }
});

Использование:

intl.formatMessage(validationMessages.required);

С параметрами:

intl.formatMessage(
  validationMessages.minLength,
  { min: 8 }
);

Интеграция с React Hook Form

Локализованная проверка обязательных полей

import { useForm } from 'react-hook-form';
import { useIntl } from 'react-intl';

function LoginForm() {
  const intl = useIntl();

  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm();

  return (
    <form>
      <input
        {...register('email', {
          required: intl.formatMessage({
            id: 'validation.required',
            defaultMessage: 'Поле обязательно'
          })
        })}
      />

      {errors.email && (
        <p>{errors.email.message}</p>
      )}
    </form>
  );
}

Валидация email

Проверка формата

register('email', {
  required: intl.formatMessage({
    id: 'validation.required'
  }),

  pattern: {
    value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
    message: intl.formatMessage({
      id: 'validation.invalidEmail',
      defaultMessage: 'Некорректный email'
    })
  }
});

Динамические сообщения

Передача параметров

register('password', {
  minLength: {
    value: 8,
    message: intl.formatMessage(
      {
        id: 'validation.password.minLength',
        defaultMessage:
          'Пароль должен содержать минимум {length} символов'
      },
      {
        length: 8
      }
    )
  }
});

Множественные формы и pluralization

Разные языки имеют разные правила склонения числительных. FormatJS автоматически учитывает правила локали.

intl.formatMessage({
  id: 'validation.files',
  defaultMessage:
    '{count, plural,' +
    ' one {# файл}' +
    ' few {# файла}' +
    ' many {# файлов}' +
    ' other {# файла}' +
    '}'
}, {
  count: 5
});

Валидация числовых значений

Проблема локализованных чисел

В разных странах используются разные разделители:

Локаль Формат
en-US 1,234.56
de-DE 1.234,56
fr-FR 1 234,56

Стандартный parseFloat() не умеет корректно работать с локализованными форматами.


Парсинг локализованных чисел

Использование Intl.NumberFormat

const numberFormatter = new Intl.NumberFormat('de-DE');

const parts = numberFormatter.formatToParts(1234.5);

console.log(parts);

Результат:

[
  { type: 'integer', value: '1' },
  { type: 'group', value: '.' },
  { type: 'integer', value: '234' },
  { type: 'decimal', value: ',' },
  { type: 'fraction', value: '5' }
]

Создание универсального parser

function parseLocaleNumber(value, locale) {
  const example = 1234.5;

  const formatter = new Intl.NumberFormat(locale);

  const parts = formatter.formatToParts(example);

  const group = parts.find(
    p => p.type === 'group'
  )?.value;

  const decimal = parts.find(
    p => p.type === 'decimal'
  )?.value;

  let normalized = value;

  if (group) {
    normalized = normalized.replaceAll(group, '');
  }

  if (decimal) {
    normalized = normalized.replace(decimal, '.');
  }

  return Number(normalized);
}

Использование:

parseLocaleNumber('1.234,56', 'de-DE');

Проверка диапазонов чисел

function validatePrice(value, intl) {
  const number = parseLocaleNumber(
    value,
    intl.locale
  );

  if (Number.isNaN(number)) {
    return intl.formatMessage({
      id: 'validation.invalidNumber',
      defaultMessage: 'Некорректное число'
    });
  }

  if (number < 0) {
    return intl.formatMessage({
      id: 'validation.negativePrice',
      defaultMessage:
        'Цена не может быть отрицательной'
    });
  }

  return true;
}

Валидация дат

Проблемы локализованных дат

Форматы даты отличаются:

Локаль Формат
en-US MM/DD/YYYY
ru-RU DD.MM.YYYY
ja-JP YYYY/MM/DD

Прямая проверка строки приводит к ошибкам интернационализации.


Форматирование дат через FormatJS

intl.formatDate(new Date(), {
  year: 'numeric',
  month: 'long',
  day: 'numeric'
});

Проверка пользовательского ввода даты

Нежелательный подход

const regex = /^\d{2}\.\d{2}\.\d{4}$/;

Такой код работает только для одной локали.


Локализованная стратегия

function validateDate(value, locale) {
  const date = new Date(value);

  if (Number.isNaN(date.getTime())) {
    return false;
  }

  return true;
}

Однако Date не умеет надёжно парсить локализованные строки. Обычно используется специализированный парсер:

  • date-fns
  • luxon
  • dayjs

Интеграция с Luxon

import { DateTime } from 'luxon';

function validateLocalizedDate(
  value,
  locale
) {
  const date = DateTime.fromFormat(
    value,
    'dd.MM.yyyy',
    { locale }
  );

  return date.isValid;
}

Валидация валют

Форматирование денежных значений

intl.formatNumber(1500, {
  style: 'currency',
  currency: 'EUR'
});

Проверка валютного ввода

function validateAmount(value, intl) {
  const amount = parseLocaleNumber(
    value,
    intl.locale
  );

  if (amount <= 0) {
    return intl.formatMessage({
      id: 'validation.invalidAmount',
      defaultMessage:
        'Сумма должна быть больше нуля'
    });
  }

  return true;
}

Валидация длины строк

Локализованные сообщения

function validateUsername(value, intl) {
  if (value.length < 3) {
    return intl.formatMessage(
      {
        id: 'validation.username.short',
        defaultMessage:
          'Имя пользователя должно содержать минимум {count} символа'
      },
      {
        count: 3
      }
    );
  }

  return true;
}

Unicode и длина строки

Обычный length работает некорректно для некоторых Unicode-символов.

'?'.length;

Результат:

2

Корректный подсчёт символов

function getCharacterLength(value) {
  return [...value].length;
}

Использование:

getCharacterLength('??');

Валидация Unicode-символов

Проверка имени пользователя

const usernameRegex =
  /^[\p{L}\p{N}_-]+$/u;

Поддерживаются:

  • латиница;
  • кириллица;
  • арабские символы;
  • азиатские иероглифы;
  • Unicode-буквы.

Безопасность и экранирование

Опасность пользовательского ввода

<input value="<script>alert(1)</script>" />

Тексты ошибок не должны вставляться через dangerouslySetInnerHTML.


Безопасный вывод сообщений

<p>{errorMessage}</p>

React автоматически экранирует содержимое.


Валидация HTML

Очистка пользовательского ввода

import DOMPurify from 'dompurify';

const clean = DOMPurify.sanitize(userInput);

Интеграция с Yup

Локализованные схемы

import * as Yup from 'yup';

const schema = (intl) =>
  Yup.object({
    email: Yup.string()
      .required(
        intl.formatMessage({
          id: 'validation.required'
        })
      )
      .email(
        intl.formatMessage({
          id: 'validation.invalidEmail'
        })
      )
  });

Динамическая смена языка

При переключении локали сообщения должны обновляться автоматически.

Неправильный подход

const message =
  intl.formatMessage({
    id: 'validation.required'
  });

Сообщение вычисляется один раз.


Правильный подход

const schema = useMemo(() => {
  return createSchema(intl);
}, [intl]);

Интеграция с Zod

import { z } from 'zod';

function createSchema(intl) {
  return z.object({
    password: z
      .string()
      .min(
        8,
        intl.formatMessage({
          id: 'validation.password.short'
        })
      )
  });
}

Валидация массивов

Проверка минимального количества элементов

function validateTags(tags, intl) {
  if (tags.length === 0) {
    return intl.formatMessage({
      id: 'validation.tags.empty',
      defaultMessage:
        'Добавьте хотя бы один тег'
    });
  }

  return true;
}

Вложенные сообщения

defineMessages({
  validationRequired: {
    id: 'form.validation.required',
    defaultMessage: 'Поле обязательно'
  }
});

Иерархические идентификаторы:

  • упрощают поиск;
  • уменьшают вероятность конфликтов;
  • улучшают поддержку крупных проектов.

Асинхронная валидация

Проверка уникальности email

async function validateEmail(
  value,
  intl
) {
  const response = await fetch(
    `/api/check-email?email=${value}`
  );

  const data = await response.json();

  if (!data.available) {
    return intl.formatMessage({
      id: 'validation.email.exists',
      defaultMessage:
        'Email уже используется'
    });
  }

  return true;
}

Локализация серверных ошибок

Ответ backend

{
  "code": "EMAIL_EXISTS"
}

Mapping ошибок

const errorMap = {
  EMAIL_EXISTS: {
    id: 'validation.email.exists',
    defaultMessage:
      'Email уже используется'
  },

  INVALID_PASSWORD: {
    id: 'validation.password.invalid',
    defaultMessage:
      'Неверный пароль'
  }
};

Использование:

intl.formatMessage(
  errorMap[serverError.code]
);

Валидация файлов

Проверка размера

function validateFile(file, intl) {
  const maxSize = 5 * 1024 * 1024;

  if (file.size > maxSize) {
    return intl.formatMessage(
      {
        id: 'validation.file.tooLarge',
        defaultMessage:
          'Размер файла превышает {size} МБ'
      },
      {
        size: 5
      }
    );
  }

  return true;
}

Проверка MIME-типа

const allowedTypes = [
  'image/png',
  'image/jpeg'
];

if (!allowedTypes.includes(file.type)) {
  return intl.formatMessage({
    id: 'validation.file.invalidType',
    defaultMessage:
      'Недопустимый тип файла'
  });
}

Централизованная система сообщений

Каталог сообщений

export const messages = {
  validation: {
    required: {
      id: 'validation.required',
      defaultMessage:
        'Поле обязательно'
    },

    invalidEmail: {
      id: 'validation.invalidEmail',
      defaultMessage:
        'Некорректный email'
    }
  }
};

Переиспользуемые validators

export function required(intl) {
  return (value) => {
    if (!value) {
      return intl.formatMessage({
        id: 'validation.required'
      });
    }

    return true;
  };
}

Использование:

register('name', {
  validate: required(intl)
});

Композиция валидаторов

function composeValidators(...validators) {
  return (value) => {
    for (const validator of validators) {
      const result = validator(value);

      if (result !== true) {
        return result;
      }
    }

    return true;
  };
}

Локализованные regex-сообщения

function regexValidator(
  regex,
  messageDescriptor,
  intl
) {
  return (value) => {
    if (!regex.test(value)) {
      return intl.formatMessage(
        messageDescriptor
      );
    }

    return true;
  };
}

Производительность

Проблема лишних вычислений

intl.formatMessage({
  id: 'validation.required'
});

Если вызывать в рендере сотни раз, производительность ухудшается.


Кэширование сообщений

const requiredMessage = useMemo(
  () =>
    intl.formatMessage({
      id: 'validation.required'
    }),
  [intl]
);

Тестирование локализованной валидации

Проверка сообщений

expect(error).toBe(
  'Поле обязательно'
);

Тестирование разных локалей

describe('validation', () => {
  test('english locale', () => {
    const error = validate('', enIntl);

    expect(error).toBe(
      'Field is required'
    );
  });

  test('russian locale', () => {
    const error = validate('', ruIntl);

    expect(error).toBe(
      'Поле обязательно'
    );
  });
});

Структура крупного проекта

src/
├── i18n/
│   ├── messages/
│   ├── locales/
│   └── validation/
│
├── validators/
│   ├── email.js
│   ├── password.js
│   └── number.js
│
├── schemas/
│   ├── loginSchema.js
│   └── profileSchema.js
│
└── forms/

Практические рекомендации

Единый источник сообщений

Все тексты ошибок должны храниться централизованно.


Отделение логики от UI

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


Отказ от жёстко заданных строк

Нежелательно:

return 'Invalid email';

Предпочтительно:

return intl.formatMessage({
  id: 'validation.invalidEmail'
});

Учёт локальных форматов

Особое внимание требуется для:

  • дат;
  • чисел;
  • валют;
  • телефонов;
  • почтовых индексов;
  • адресов.

Серверная и клиентская валидация

Клиентская проверка улучшает UX, но не заменяет серверную валидацию.


Унификация кодов ошибок

Backend должен возвращать коды ошибок, а не готовые тексты.

Пример:

{
  "code": "PASSWORD_TOO_SHORT"
}

Frontend локализует сообщение самостоятельно.


Изоляция сообщений от бизнес-логики

Валидаторы должны возвращать:

  • true
  • код ошибки
  • descriptor сообщения

а не напрямую взаимодействовать с DOM.


Поддержка accessibility

Ошибки должны быть доступны screen reader-системам.

<input
  aria-invalid={!!errors.email}
  aria-describedby="email-error"
/>

<p id="email-error">
  {errors.email?.message}
</p>