При разработке многоязычных приложений валидация входных данных становится сложнее из-за различий в форматах чисел, дат, валют, единиц измерения и пользовательских сообщений. Библиотека FormatJS предоставляет набор инструментов для локализации сообщений и форматирования данных, что позволяет строить систему проверки пользовательского ввода с учётом региональных особенностей.
Валидация в интернационализированном приложении обычно включает:
Наиболее часто FormatJS используется совместно с:
react-intlintl-messageformat@formatjs/intlFormik,
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 }
);
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>
);
}
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
}
)
}
});
Разные языки имеют разные правила склонения числительных. 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() не умеет корректно работать с
локализованными форматами.
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' }
]
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 |
Прямая проверка строки приводит к ошибкам интернационализации.
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-fnsluxondayjsimport { 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;
}
Обычный length работает некорректно для некоторых
Unicode-символов.
'?'.length;
Результат:
2
function getCharacterLength(value) {
return [...value].length;
}
Использование:
getCharacterLength('??');
const usernameRegex =
/^[\p{L}\p{N}_-]+$/u;
Поддерживаются:
<input value="<script>alert(1)</script>" />
Тексты ошибок не должны вставляться через
dangerouslySetInnerHTML.
<p>{errorMessage}</p>
React автоматически экранирует содержимое.
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(userInput);
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]);
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: 'Поле обязательно'
}
});
Иерархические идентификаторы:
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;
}
{
"code": "EMAIL_EXISTS"
}
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;
}
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'
}
}
};
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;
};
}
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/
Все тексты ошибок должны храниться централизованно.
Валидаторы не должны зависеть от компонентов интерфейса.
Нежелательно:
return 'Invalid email';
Предпочтительно:
return intl.formatMessage({
id: 'validation.invalidEmail'
});
Особое внимание требуется для:
Клиентская проверка улучшает UX, но не заменяет серверную валидацию.
Backend должен возвращать коды ошибок, а не готовые тексты.
Пример:
{
"code": "PASSWORD_TOO_SHORT"
}
Frontend локализует сообщение самостоятельно.
Валидаторы должны возвращать:
trueа не напрямую взаимодействовать с DOM.
Ошибки должны быть доступны screen reader-системам.
<input
aria-invalid={!!errors.email}
aria-describedby="email-error"
/>
<p id="email-error">
{errors.email?.message}
</p>