Валидация данных бесполезна без корректного представления ошибок пользователю. Библиотека class-validator возвращает структурированную информацию о нарушениях правил, которую можно преобразовать в удобный интерфейс: сообщения под полями формы, подсветку ошибок, всплывающие уведомления, списки проблем или API-ответы.
Результат работы validate() представляет собой массив
объектов ValidationError:
import { validate, IsEmail, Length } from 'class-validator'
class UserDto {
@IsEmail({}, { message: 'Некорректный email' })
email: string
@Length(5, 20, {
message: 'Пароль должен содержать от 5 до 20 символов',
})
password: string
}
const dto = new UserDto()
dto.email = 'wrong'
dto.password = '123'
const errors = await validate(dto)
console.log(errors)
Пример структуры ошибки:
[
{
property: 'email',
value: 'wrong',
constraints: {
isEmail: 'Некорректный email'
}
},
{
property: 'password',
value: '123',
constraints: {
isLength: 'Пароль должен содержать от 5 до 20 символов'
}
}
]
Основные поля объекта ValidationError:
| Поле | Описание |
|---|---|
property |
Имя поля |
value |
Переданное значение |
constraints |
Объект с сообщениями ошибок |
children |
Ошибки вложенных объектов |
Интерфейс обычно не работает напрямую со структурой
ValidationError. Чаще всего ошибки преобразуются в объект
вида:
{
email: ['Некорректный email'],
password: ['Пароль слишком короткий']
}
Пример преобразования:
import { ValidationError } from 'class-validator'
function formatErrors(errors: ValidationError[]) {
const result = {}
for (const error of errors) {
result[error.property] = Object.values(error.constraints || {})
}
return result
}
Использование:
const validationErrors = await validate(dto)
const formatted = formatErrors(validationErrors)
console.log(formatted)
Результат:
{
email: ['Некорректный email'],
password: ['Пароль должен содержать от 5 до 20 символов']
}
Такой формат удобно использовать в:
Типичная схема отображения ошибок в React:
const [errors, setErrors] = useState({})
Проверка формы:
async function handleSubmit() {
const dto = new UserDto()
dto.email = email
dto.password = password
const validationErrors = await validate(dto)
if (validationErrors.length > 0) {
setErrors(formatErrors(validationErrors))
return
}
console.log('Форма корректна')
}
Вывод ошибок:
<div>
<input
value={email}
onCha nge={(e) => setEmail(e.target.value)}
/>
{errors.email && (
<p>{errors.email[0]}</p>
)}
</div>
Ошибки часто сопровождаются изменением стиля элемента:
<input
className={errors.email ? 'input error' : 'input'}
/>
CSS:
.input {
border: 1px solid #ccc;
}
.input.error {
border-color: red;
}
Возможные варианты визуализации:
Одно поле может нарушать несколько ограничений одновременно.
Пример:
class PasswordDto {
@Length(8, 30, {
message: 'Минимум 8 символов',
})
@Matches(/[A-Z]/, {
message: 'Требуется заглавная буква',
})
@Matches(/[0-9]/, {
message: 'Требуется цифра',
})
password: string
}
Результат:
{
password: [
'Минимум 8 символов',
'Требуется заглавная буква',
'Требуется цифра'
]
}
Отображение:
<ul>
{errors.password?.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
Текст ошибки задаётся через параметр message.
@IsEmail({}, {
message: 'Введите корректный email'
})
email: string
Поддерживается функция:
@Length(5, 10, {
message: (args) => {
return `Длина поля ${args.property} должна быть от 5 до 10`
}
})
username: string
Аргумент содержит:
| Поле | Описание |
|---|---|
value |
Значение |
property |
Имя поля |
targetName |
Имя класса |
constraints |
Параметры валидатора |
Ошибки интерфейса часто переводятся через i18n-системы.
Пример:
@IsEmail({}, {
message: 'validation.email.invalid'
})
email: string
На клиенте:
t(errors.email[0])
Преимущества:
В REST API ошибки обычно возвращаются в едином формате.
Пример ответа:
{
"errors": {
"email": [
"Некорректный email"
],
"password": [
"Минимум 8 символов"
]
}
}
Формирование ответа:
function buildErrorResponse(errors: ValidationError[]) {
const result = {}
for (const error of errors) {
result[error.property] = Object.values(
error.constraints || {}
)
}
return {
errors: result,
}
}
При использовании вложенных DTO ошибки находятся в
children.
Пример DTO:
class AddressDto {
@Length(2, 50)
city: string
}
class UserDto {
@ValidateNested()
@Type(() => AddressDto)
address: AddressDto
}
Ошибка:
[
{
property: 'address',
children: [
{
property: 'city',
constraints: {
isLength: 'city must be longer...'
}
}
]
}
]
Для сложных форм требуется рекурсивная обработка.
Пример:
function parseErrors(
errors: ValidationError[],
parent = ''
) {
const result = {}
for (const error of errors) {
const path = parent
? `${parent}.${error.property}`
: error.property
if (error.constraints) {
result[path] = Object.values(error.constraints)
}
if (error.children?.length) {
Object.assign(
result,
parseErrors(error.children, path)
)
}
}
return result
}
Результат:
{
'address.city': [
'Название города слишком короткое'
]
}
В динамических формах поля могут генерироваться автоматически.
Пример структуры:
{
fields: [
{
name: 'email',
value: '',
errors: []
}
]
}
Ошибки связываются по имени поля:
field.errors = formattedErrors[field.name] || []
Это особенно полезно при:
Пример DTO:
class TagDto {
@Length(2, 20)
name: string
}
class PostDto {
@ValidateNested({ each: true })
@Type(() => TagDto)
tags: TagDto[]
}
Ошибки:
{
'tags.0.name': [
'Минимум 2 символа'
]
}
Интерфейс может отображать ошибку рядом с конкретным элементом массива.
Популярная схема интеграции:
npm install @hookform/resolvers
Пример:
import { classValidatorResolver } from '@hookform/resolvers/class-validator'
import { useForm } from 'react-hook-form'
const resolver = classValidatorResolver(UserDto)
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver,
})
Вывод:
<input {...register('email')} />
<p>{errors.email?.message}</p>
Преимущества:
В NestJS ошибки валидации обычно обрабатываются через
ValidationPipe.
Настройка:
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
}),
)
Ответ по умолчанию:
{
"statusCode": 400,
"message": [
"email must be an email"
],
"error": "Bad Request"
}
Формат ошибок можно полностью изменить.
new ValidationPipe({
exceptionFactory: (errors) => {
return new BadRequestException({
errors: formatErrors(errors),
})
},
})
Результат:
{
"errors": {
"email": [
"Некорректный email"
]
}
}
По умолчанию ValidationError может содержать:
Для API это иногда нежелательно.
Настройка:
validate(dto, {
validationError: {
target: false,
value: false,
},
})
Результат становится безопаснее:
[
{
"property": "email",
"constraints": {
"isEmail": "Некорректный email"
}
}
]
Проверка может запускаться:
Пример проверки при вводе:
async function validateEmail(value: string) {
const dto = new UserDto()
dto.email = value
const errors = await validate(dto)
return formatErrors(errors)
}
Постоянная проверка при каждом символе может создавать нагрузку.
Пример debounce:
const debouncedValidate = debounce(async (value) => {
const errors = await validateEmail(value)
setErrors(errors)
}, 300)
Это уменьшает:
Ошибка обычно показывается только после взаимодействия с полем.
Пример:
{
touched.email && errors.email && (
<p>{errors.email[0]}</p>
)
}
Такой подход улучшает UX:
Иногда требуется отображать ошибки списком.
Пример:
<div>
{Object.entries(errors).map(([field, messages]) => (
<div key={field}>
<strong>{field}</strong>
<ul>
{messages.map((msg) => (
<li key={msg}>{msg}</li>
))}
</ul>
</div>
))}
</div>
Не всегда требуется показывать все ошибки одновременно.
Пример показа только первой ошибки:
const firstError =
Object.values(error.constraints || {})[0]
Интерфейс становится менее перегруженным.
Названия DTO-полей часто непригодны для интерфейса.
Плохой вариант:
userName
Лучший вариант:
Имя пользователя
Таблица отображения:
const labels = {
userName: 'Имя пользователя',
email: 'Email',
}
Использование:
const fieldLabel = labels[field]
Распространённая архитектура:
{
values: {},
errors: {},
touched: {},
}
Пример:
errors.email
errors.password
errors.profile.city
Такое представление совместимо практически со всеми UI-библиотеками.
Некоторые валидаторы работают асинхронно.
Пример проверки уникальности email:
@ValidatorConstraint({ async: true })
class IsEmailUniqueConstraint {
async validate(email: string) {
return !(await userExists(email))
}
}
Интерфейс должен учитывать состояния:
Некоторые ошибки невозможно проверить на клиенте.
Например:
Сервер может вернуть:
{
"errors": {
"email": [
"Пользователь уже существует"
]
}
}
Интерфейс обрабатывает ответ так же, как локальную валидацию.
Хорошая практика — одинаковая структура ошибок на клиенте и сервере.
Пример:
type ValidationErrors = {
[key: string]: string[]
}
Преимущества:
Пример переиспользуемого компонента:
type Props = {
errors?: string[]
}
export function FieldErrors({
errors,
}: Props) {
if (!errors?.length) {
return null
}
return (
<ul>
{errors.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
)
}
Использование:
<FieldErrors errors={errors.email} />
Ошибки могут храниться в:
Пример:
{
form: {
values: {},
errors: {},
}
}
Это позволяет:
Типизация уменьшает количество ошибок интерфейса.
Пример:
type FormErrors<T> = {
[K in keyof T]?: string[]
}
Использование:
type UserErrors = FormErrors<UserDto>
Ошибки обычно очищаются:
Пример:
setErrors({})
Либо для конкретного поля:
setErrors((prev) => ({
...prev,
email: undefined,
}))
Хорошая система отображения ошибок обладает следующими свойствами: