Плоские и вложенные ошибки

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

import { z } from "zod";

const schema = z.object({
  user: z.object({
    name: z.string(),
  }),
});

schema.parse({ user: { name: 123 } });

Результат такой валидации формирует ошибку, внутри которой будет один элемент issues, но с путём:

{
  issues: [
    {
      path: ["user", "name"],
      message: "Expected string, received number",
      code: "invalid_type"
    }
  ]
}

Плоская модель ошибок

Плоская ошибка в Zod — это ситуация, когда каждый элемент issue относится к одному конкретному полю без вложенной структуры обработки.

Характерные признаки:

  • path содержит один уровень или пустой массив
  • каждая ошибка независима от других
  • нет группировки по объектам

Пример:

const schema = z.object({
  name: z.string(),
  age: z.number(),
});

schema.parse({ name: 123, age: "old" });

Результат:

[
  { path: ["name"], message: "Expected string, received number" },
  { path: ["age"], message: "Expected number, received string" }
]

Такая форма удобна для простого отображения списком или логирования, поскольку каждая ошибка — автономная единица.

Вложенные ошибки и структура path

Вложенность возникает при использовании z.object, z.array, z.tuple и любых комбинированных схем.

Ключевая концепция — path как путь в дереве данных.

Пример вложенности:

const schema = z.object({
  user: z.object({
    address: z.object({
      city: z.string(),
    }),
  }),
});

Ошибка:

schema.parse({
  user: {
    address: {
      city: 123,
    },
  },
});

Результат:

{
  path: ["user", "address", "city"],
  message: "Expected string, received number"
}

Семантика path

path всегда является массивом:

  • индекс массива → для array
  • ключ объекта → для object
  • смешанные случаи → для вложенных структур

Пример массива:

const schema = z.array(z.object({ name: z.string() }));

schema.parse([{ name: 123 }]);

Ошибка:

{
  path: [0, "name"],
  message: "Expected string, received number"
}

issues как единый источник правды

ZodError.issues — это плоский список, даже если данные вложенные. Zod не хранит дерево ошибок в виде дерева — только список с путями.

Структура:

type ZodIssue = {
  path: (string | number)[];
  message: string;
  code: string;
};

Это означает:

  • нет вложенных объектов ошибок
  • иерархия восстанавливается только через path
  • UI-слой сам решает, как группировать ошибки

Проблема визуализации: плоское vs вложенное представление

Одни и те же данные могут требовать двух форм отображения:

Плоский список

Используется для:

  • логирования
  • дебага
  • API-ответов

Пример:

[
  "user.name: invalid type",
  "user.address.city: required"
]

Вложенная структура

Используется для:

  • форм
  • UI-валидации
  • подсветки полей

Пример группировки:

{
  user: {
    name: "invalid type",
    address: {
      city: "required"
    }
  }
}

flatten() как инструмент перехода к плоской модели

Zod предоставляет метод flatten() для преобразования ошибок в более простой формат:

const result = schema.safeParse(data);

if (!result.success) {
  const flat = result.error.flatten();
}

Структура результата:

{
  formErrors: string[],
  fieldErrors: {
    user?: string[],
    "user.name"?: string[]
  }
}

Особенность:

  • ошибки верхнего уровня → formErrors
  • ошибки полей → fieldErrors
  • вложенность «схлопывается» в ключи строкового формата

format() как сохранение иерархии

Метод format() сохраняет структуру объекта и раскладывает ошибки по тем же ключам, что и схема.

Пример:

const result = schema.safeParse(data);

if (!result.success) {
  const formatted = result.error.format();
}

Результат:

{
  user: {
    _errors: [],
    name: {
      _errors: ["Expected string, received number"]
    },
    address: {
      city: {
        _errors: ["Expected string, received number"]
      }
    }
  }
}

Отличие flatten и format

Метод Представление Назначение
flatten() плоское обработка списков ошибок
format() вложенное UI-формы

Вложенные ошибки в refine и superRefine

При использовании кастомной валидации через refine и superRefine появляется возможность создавать ошибки на любом уровне вложенности.

const schema = z.object({
  password: z.string(),
  confirm: z.string(),
}).superRefine((data, ctx) => {
  if (data.password !== data.confirm) {
    ctx.addIssue({
      path: ["confirm"],
      message: "Пароли не совпадают",
      code: "custom",
    });
  }
});

Даже кастомная ошибка сохраняет структуру:

  • путь указывается вручную
  • ошибка становится частью issues
  • не создаётся отдельной иерархии

Особенности union-ошибок

При использовании z.union() ошибки могут выглядеть как вложенные альтернативные ветки.

const schema = z.union([
  z.object({ type: z.literal("a"), value: z.string() }),
  z.object({ type: z.literal("b"), count: z.number() }),
]);

Если вход не соответствует ни одному варианту:

  • каждая ветка генерирует свои issues
  • Zod объединяет их в общий список
  • path может быть пустым или частично заполненным

Это создаёт эффект «псевдовложенности», но структура остаётся плоской.

Группировка ошибок по path вручную

Поскольку Zod хранит ошибки в плоском массиве, вложенность часто восстанавливается вручную:

function groupByPath(issues) {
  const tree = {};

  for (const issue of issues) {
    let current = tree;

    for (const key of issue.path) {
      current[key] = current[key] || {};
      current = current[key];
    }

    current._errors = current._errors || [];
    current._errors.push(issue.message);
  }

  return tree;
}

Такая трансформация необходима при построении сложных UI-форм.

Практические различия моделей ошибок

Плоская модель эффективна когда:

  • требуется API-ответ
  • нужна интеграция с логированием
  • ошибки обрабатываются централизованно

Вложенная модель эффективна когда:

  • данные отображаются как форма
  • требуется привязка к полям UI
  • важна читаемая структура объекта

Поведение path в сложных структурах

В реальных схемах path может содержать:

  • строки (ключи объектов)
  • числа (индексы массивов)
  • комбинации

Пример:

["users", 3, "posts", 1, "title"]

Это означает цепочку доступа:

users[3].posts[1].title

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