Стратегии обработки множественных ошибок

При проверке сложных структур данных ошибка редко бывает единственной. Объект может одновременно содержать:

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

Валидация «до первой ошибки» подходит только для примитивных сценариев. В реальных приложениях требуется собрать полный набор проблем за один проход.

В библиотеке Superstruct механизм множественных ошибок строится вокруг:

  • StructError;
  • свойства failures();
  • пользовательских сообщений;
  • агрегации ошибок вложенных структур;
  • стратегий отображения ошибок в UI;
  • нормализации ошибок для API.

Поведение Superstruct по умолчанию

Базовая проверка через assert() выбрасывает исключение при первой найденной ошибке.

import { object, string, number, assert } fr om 'superstruct'

const User = object({
  name: string(),
  age: number(),
})

assert({
  name: 42,
  age: '18',
}, User)

Ошибка:

Expected a string, but received: 42

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


Получение полного списка ошибок

Метод failures()

Ключевой механизм множественной обработки — error.failures().

import {
  object,
  string,
  number,
  assert,
} fr om 'superstruct'

const User = object({
  name: string(),
  age: number(),
})

try {
  assert({
    name: 42,
    age: '18',
  }, User)
} catch (error) {
  console.log([...error.failures()])
}

Результат:

[
  {
    value: 42,
    type: 'string',
    path: ['name'],
    branch: [ ... ],
    key: 'name',
    message: 'Expected a string, but received: 42'
  },
  {
    value: '18',
    type: 'number',
    path: ['age'],
    branch: [ ... ],
    key: 'age',
    message: 'Expected a number, but received: "18"'
  }
]

Структура объекта ошибки

Каждый элемент failures() содержит детализированное описание проблемы.

value

Некорректное значение.

{
  value: '18'
}

type

Ожидаемый тип.

{
  type: 'number'
}

path

Путь к проблемному полю.

{
  path: ['profile', 'contacts', 0, 'email']
}

Особенно важно при работе с вложенными объектами и массивами.


key

Последний сегмент пути.

{
  key: 'email'
}

branch

Полный путь объектов от корня.

{
  branch: [
    rootObject,
    profileObject,
    contactsArray,
    emailObject
  ]
}

Полезно для сложной диагностики и построения контекстных сообщений.


message

Готовое текстовое описание.

{
  message: 'Expected a string, but received: 123'
}

Стратегия полной агрегации ошибок

Проверка сложных объектов

import {
  object,
  string,
  number,
  boolean,
  array,
  assert,
} from 'superstruct'

const Product = object({
  title: string(),
  price: number(),
  active: boolean(),
  tags: array(string()),
})

try {
  assert({
    title: 100,
    price: '500',
    active: 'yes',
    tags: [1, 2, 3],
  }, Product)
} catch (error) {
  for (const failure of error.failures()) {
    console.log(failure.path, failure.message)
  }
}

Результат:

['title'] Expected a string, but received: 100
['price'] Expected a number, but received: "500"
['active'] Expected a boolean, but received: "yes"
['tags', 0] Expected a string, but received: 1
['tags', 1] Expected a string, but received: 2
['tags', 2] Expected a string, but received: 3

Нормализация ошибок

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

Преобразование в плоский объект

function formatErrors(error) {
  const result = {}

  for (const failure of error.failures()) {
    const path = failure.path.join('.')

    result[path] = failure.message
  }

  return result
}

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

try {
  assert(data, Product)
} catch (error) {
  console.log(formatErrors(error))
}

Результат:

{
  'title': 'Expected a string, but received: 100',
  'price': 'Expected a number, but received: "500"',
  'tags.0': 'Expected a string, but received: 1'
}

Группировка ошибок по полям

Иногда одному полю соответствует несколько ограничений.

Агрегация массива ошибок

function collectErrors(error) {
  const map = {}

  for (const failure of error.failures()) {
    const path = failure.path.join('.')

    if (!map[path]) {
      map[path] = []
    }

    map[path].push(failure.message)
  }

  return map
}

Результат:

{
  email: [
    'Invalid email format',
    'Email is required'
  ]
}

Обработка ошибок массивов

Валидация элементов массива

import {
  array,
  string,
  assert,
} from 'superstruct'

const Tags = array(string())

try {
  assert([
    'javascript',
    100,
    true,
  ], Tags)
} catch (error) {
  console.log([...error.failures()])
}

Результат:

[
  {
    path: [1],
    value: 100
  },
  {
    path: [2],
    value: true
  }
]

Стратегия индексирования ошибок

Для UI важно точно понимать позицию элемента.

Преобразование индексов

function mapArrayErrors(error) {
  return [...error.failures()].map(failure => ({
    index: failure.path[0],
    message: failure.message,
  }))
}

Результат:

[
  {
    index: 1,
    message: 'Expected a string, but received: 100'
  }
]

Ошибки вложенных структур

Глубокая валидация

import {
  object,
  string,
  number,
  array,
  assert,
} from 'superstruct'

const Address = object({
  city: string(),
  zip: number(),
})

const User = object({
  name: string(),
  addresses: array(Address),
})

try {
  assert({
    name: 100,
    addresses: [
      {
        city: 123,
        zip: '0001',
      }
    ]
  }, User)
} catch (error) {
  for (const failure of error.failures()) {
    console.log(failure.path)
  }
}

Результат:

['name']
['addresses', 0, 'city']
['addresses', 0, 'zip']

Построение древовидной структуры ошибок

Для сложных форм плоский список неудобен.

Конвертация в дерево

function buildErrorTree(error) {
  const tree = {}

  for (const failure of error.failures()) {
    let current = tree

    for (let i = 0; i < failure.path.length; i++) {
      const segment = failure.path[i]

      if (i === failure.path.length - 1) {
        current[segment] = failure.message
      } else {
        current[segment] ||= {}
        current = current[segment]
      }
    }
  }

  return tree
}

Результат:

{
  addresses: {
    0: {
      city: 'Expected a string...',
      zip: 'Expected a number...'
    }
  }
}

Кастомные ошибки и множественная обработка

Использование refine()

import {
  string,
  refine,
} from 'superstruct'

const Email = refine(
  string(),
  'email',
  value => {
    return value.includes('@')
  }
)

Ошибка:

Expected a value of type `email`

Несколько правил для одного поля

Цепочка refine()

const Password = refine(
  refine(
    string(),
    'min_length',
    value => value.length >= 8
  ),
  'uppercase',
  value => /[A-Z]/.test(value)
)

Проблема:

'abc'

Может вызвать несколько ошибок одновременно.


Генерация собственных сообщений

Пользовательские тексты

import {
  define,
} from 'superstruct'

const PositiveNumber = define(
  'PositiveNumber',
  value => {
    if (typeof value !== 'number') {
      return 'Value must be a number'
    }

    if (value <= 0) {
      return 'Value must be positive'
    }

    return true
  }
)

Сбор множественных пользовательских ошибок

Возврат нескольких проблем

Стандартный define() возвращает одну ошибку. Для сложной логики применяется разбиение проверки.

const PasswordLength = refine(
  string(),
  'PasswordLength',
  value => value.length >= 8
)

const PasswordUppercase = refine(
  string(),
  'PasswordUppercase',
  value => /[A-Z]/.test(value)
)

const PasswordNumber = refine(
  string(),
  'PasswordNumber',
  value => /\d/.test(value)
)

Композиция:

const Password = object({
  password: PasswordLength,
})

Либо ручная стратегия:

function validatePassword(value) {
  const errors = []

  if (value.length < 8) {
    errors.push('Minimum length is 8')
  }

  if (!/[A-Z]/.test(value)) {
    errors.push('Uppercase letter required')
  }

  if (!/\d/.test(value)) {
    errors.push('Number required')
  }

  return errors
}

Стратегия fail-soft

Частичная обработка данных

Иногда необходимо сохранить валидные поля даже при наличии ошибок.

import {
  validate,
} from 'superstruct'

const [error, value] = validate(data, User)

Если ошибки есть:

console.log(error)

Но при этом:

console.log(value)

может содержать частично корректную структуру.


Стратегия fail-fast

Немедленное завершение

Для высоконагруженных сценариев иногда выгоднее прекращать проверку после первой ошибки.

try {
  assert(data, User)
} catch (error) {
  console.log(error.message)
}

Подход уменьшает:

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

Гибридная стратегия

Часто применяется комбинированный подход:

Сценарий Стратегия
API Полная агрегация
Streaming Fail-fast
Формы Полная агрегация
Внутренние сервисы Частичная агрегация
CLI Первая ошибка

Валидация API-запросов

Унификация формата ответа

function validationMiddleware(struct) {
  return (req, res, next) => {
    const [error] = validate(req.body, struct)

    if (!error) {
      return next()
    }

    return res.status(400).json({
      errors: [...error.failures()].map(failure => ({
        field: failure.path.join('.'),
        message: failure.message,
      }))
    })
  }
}

Ответ:

{
  "errors": [
    {
      "field": "email",
      "message": "Expected a string"
    },
    {
      "field": "age",
      "message": "Expected a number"
    }
  ]
}

Интеграция с UI-формами

Ошибки для React Form

function toFormErrors(error) {
  return [...error.failures()].reduce((acc, failure) => {
    acc[failure.path.join('.')] = failure.message
    return acc
  }, {})
}

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

Таблица переводов

const messages = {
  string: 'Ожидается строка',
  number: 'Ожидается число',
  boolean: 'Ожидается булево значение',
}

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

function translateFailure(failure) {
  return {
    field: failure.path.join('.'),
    message: messages[failure.type],
  }
}

Обработка неизвестных ошибок

Не все ошибки относятся к валидации.

Проверка типа исключения

import { StructError } from 'superstruct'

try {
  assert(data, User)
} catch (error) {
  if (error instanceof StructError) {
    console.log([...error.failures()])
  } else {
    throw error
  }
}

Производительность множественной валидации

Полная агрегация ошибок требует:

  • обхода всей структуры;
  • накопления всех failure-объектов;
  • хранения путей и ветвей.

Особенно дорого обходятся:

  • глубокие вложенности;
  • большие массивы;
  • сложные refine()-проверки;
  • регулярные выражения;
  • асинхронные предварительные преобразования.

Оптимизация обработки ошибок

Ограничение количества ошибок

function takeFailures(error, lim it = 10) {
  return [...error.failures()].slice(0, lim it)
}

Ранний выход для массивов

function validateItems(items) {
  for (const item of items) {
    const [error] = validate(item, ItemStruct)

    if (error) {
      return error
    }
  }
}

Централизованный обработчик ошибок

Единая точка обработки

function parseStructError(error) {
  if (!(error instanceof StructError)) {
    return null
  }

  return [...error.failures()].map(failure => ({
    path: failure.path,
    field: failure.path.join('.'),
    type: failure.type,
    message: failure.message,
    value: failure.value,
  }))
}

Типичные архитектурные стратегии

1. Плоская карта ошибок

{
  'user.name': 'Required',
  'user.email': 'Invalid email'
}

Подходит для:

  • HTML-форм;
  • React Hook Form;
  • Formik.

2. Древовидная структура

{
  user: {
    profile: {
      email: 'Invalid'
    }
  }
}

Подходит для:

  • сложных JSON-документов;
  • конфигураций;
  • nested forms.

3. Массив объектов ошибок

[
  {
    path: ['user', 'email'],
    message: 'Invalid'
  }
]

Подходит для:

  • REST API;
  • логирования;
  • аналитики;
  • сериализации.

Сравнение стратегий

Подход Преимущества Недостатки
Fail-fast Максимальная скорость Мало информации
Полная агрегация Удобство UI Более высокая нагрузка
Ограниченная агрегация Баланс производительности Дополнительная логика
Древовидные ошибки Удобство вложенных структур Более сложная обработка
Плоские ошибки Простая интеграция Потеря контекста

Практический шаблон промышленной обработки ошибок

import {
  validate,
  StructError,
} from 'superstruct'

export function validateData(data, struct) {
  const [error, value] = validate(data, struct)

  if (!error) {
    return {
      success: true,
      data: value,
      errors: [],
    }
  }

  if (!(error instanceof StructError)) {
    throw error
  }

  return {
    success: false,
    data: null,
    errors: [...error.failures()].map(failure => ({
      field: failure.path.join('.'),
      path: failure.path,
      type: failure.type,
      value: failure.value,
      message: failure.message,
    })),
  }
}

Пример результата:

{
  success: false,
  data: null,
  errors: [
    {
      field: 'email',
      path: ['email'],
      type: 'string',
      value: 100,
      message: 'Expected a string, but received: 100'
    }
  ]
}