Вложенные структуры

Валидация вложенных данных — одна из ключевых задач при работе с API, формами, конфигурациями и сложными объектами состояния. Библиотека Zod предоставляет мощные механизмы описания вложенных схем с полной типизацией и поддержкой глубоких структур.


Базовые вложенные объекты

Вложенные структуры создаются через комбинацию z.object() внутри других объектов.

import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),

  address: z.object({
    city: z.string(),
    street: z.string(),
    zipCode: z.string(),
  }),
});

Проверка:

const result = UserSchema.parse({
  id: 1,
  name: "Alex",

  address: {
    city: "Berlin",
    street: "Main Street",
    zipCode: "10115",
  },
});

Тип автоматически выводится:

type User = z.infer<typeof UserSchema>;

Результат:

type User = {
  id: number;
  name: string;

  address: {
    city: string;
    street: string;
    zipCode: string;
  };
};

Многоуровневые структуры

Zod поддерживает неограниченную глубину вложенности.

const CompanySchema = z.object({
  name: z.string(),

  owner: z.object({
    personal: z.object({
      firstName: z.string(),
      lastName: z.string(),
    }),

    contacts: z.object({
      email: z.string().email(),
      phone: z.string(),
    }),
  }),
});

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

CompanySchema.parse({
  name: "Tech Corp",

  owner: {
    personal: {
      firstName: "John",
      lastName: "Smith",
    },

    contacts: {
      email: "john@example.com",
      phone: "+123456789",
    },
  },
});

Вложенные массивы объектов

Один из самых распространённых сценариев — массивы объектов внутри объекта.

const OrderSchema = z.object({
  orderId: z.string(),

  products: z.array(
    z.object({
      id: z.number(),
      title: z.string(),
      price: z.number(),
    })
  ),
});

Пример:

OrderSchema.parse({
  orderId: "ORD-001",

  products: [
    {
      id: 1,
      title: "Laptop",
      price: 1200,
    },

    {
      id: 2,
      title: "Mouse",
      price: 50,
    },
  ],
});

Глубокие массивы

Массивы могут содержать объекты с собственными вложенными массивами.

const BlogSchema = z.object({
  title: z.string(),

  comments: z.array(
    z.object({
      text: z.string(),

      author: z.object({
        id: z.number(),
        username: z.string(),
      }),

      replies: z.array(
        z.object({
          text: z.string(),
        })
      ),
    })
  ),
});

Optional во вложенных объектах

Отдельные вложенные структуры могут быть необязательными.

const ProfileSchema = z.object({
  username: z.string(),

  social: z
    .object({
      twitter: z.string(),
      github: z.string(),
    })
    .optional(),
});

Допустимые данные:

ProfileSchema.parse({
  username: "alex",
});

Или:

ProfileSchema.parse({
  username: "alex",

  social: {
    twitter: "@alex",
    github: "alexdev",
  },
});

Nullable во вложенных структурах

nullable() разрешает значение null.

const ArticleSchema = z.object({
  title: z.string(),

  metadata: z
    .object({
      views: z.number(),
      likes: z.number(),
    })
    .nullable(),
});

Комбинация optional и nullable

const ConfigSchema = z.object({
  cache: z
    .object({
      enabled: z.boolean(),
    })
    .nullable()
    .optional(),
});

Допустимы:

{}
{
  cache: null
}
{
  cache: {
    enabled: true
  }
}

Partial для вложенных объектов

partial() делает поля объекта необязательными.

const SettingsSchema = z.object({
  theme: z.string(),
  language: z.string(),
});

const PartialSettings = SettingsSchema.partial();

Результат:

{
  theme?: string;
  language?: string;
}

Deep Partial

Стандартный partial() работает только на верхнем уровне.

const UserSchema = z.object({
  profile: z.object({
    firstName: z.string(),
    lastName: z.string(),
  }),
});

const PartialUser = UserSchema.partial();

Результат:

{
  profile?: {
    firstName: string;
    lastName: string;
  };
}

Внутренние поля остаются обязательными.

Для глубокой модификации применяется deepPartial():

const DeepPartialUser = UserSchema.deepPartial();

Теперь:

{
  profile?: {
    firstName?: string;
    lastName?: string;
  };
}

Расширение вложенных структур

Метод extend() позволяет дополнять схемы.

const AddressSchema = z.object({
  city: z.string(),
  street: z.string(),
});

const ExtendedAddressSchema = AddressSchema.extend({
  country: z.string(),
});

Переиспользование вложенных схем

Одна из главных сильных сторон Zod — композиция.

const AddressSchema = z.object({
  city: z.string(),
  street: z.string(),
});

const UserSchema = z.object({
  name: z.string(),
  address: AddressSchema,
});

const CompanySchema = z.object({
  title: z.string(),
  office: AddressSchema,
});

Merge для сложных структур

const BaseSchema = z.object({
  id: z.number(),
});

const ProfileSchema = z.object({
  username: z.string(),
});

const UserSchema = BaseSchema.merge(ProfileSchema);

Результат:

{
  id: number;
  username: string;
}

Pick и вложенные данные

Извлечение части схемы.

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string(),
});

const PublicUserSchema = UserSchema.pick({
  id: true,
  name: true,
});

Omit для удаления полей

const PrivateUserSchema = UserSchema.omit({
  password: true,
});

Работа со вложенными массивами через .element

Получение схемы элемента массива:

const TagsSchema = z.array(z.string());

const TagElement = TagsSchema.element;

Record внутри вложенных структур

record() полезен для динамических ключей.

const TranslationSchema = z.object({
  translations: z.record(z.string()),
});

Пример:

{
  translations: {
    en: "Hello",
    de: "Hallo",
    fr: "Bonjour",
  }
}

Вложенные union-схемы

const PaymentSchema = z.object({
  method: z.union([
    z.object({
      type: z.literal("card"),
      cardNumber: z.string(),
    }),

    z.object({
      type: z.literal("paypal"),
      email: z.string().email(),
    }),
  ]),
});

Discriminated Union во вложенных объектах

Более эффективный вариант:

const PaymentSchema = z.object({
  payment: z.discriminatedUnion("type", [
    z.object({
      type: z.literal("card"),
      cardNumber: z.string(),
    }),

    z.object({
      type: z.literal("paypal"),
      email: z.string(),
    }),
  ]),
});

Lazy для рекурсивных структур

Рекурсивные структуры требуют z.lazy().

Дерево категорий

const CategorySchema = z.lazy(() =>
  z.object({
    name: z.string(),

    children: z.array(CategorySchema),
  })
);

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

CategorySchema.parse({
  name: "Programming",

  children: [
    {
      name: "JavaScript",
      children: [],
    },

    {
      name: "Python",
      children: [],
    },
  ],
});

Рекурсивные комментарии

const CommentSchema = z.lazy(() =>
  z.object({
    id: z.number(),
    text: z.string(),

    replies: z.array(CommentSchema),
  })
);

Валидация вложенных полей

Методы можно применять на любом уровне.

const UserSchema = z.object({
  profile: z.object({
    email: z.string().email(),
    age: z.number().min(18),
  }),
});

refine во вложенных структурах

const PasswordSchema = z.object({
  credentials: z.object({
    password: z.string(),
    confirmPassword: z.string(),
  }),
}).refine(
  (data) =>
    data.credentials.password ===
    data.credentials.confirmPassword,
  {
    path: ["credentials", "confirmPassword"],
    message: "Пароли не совпадают",
  }
);

superRefine для сложной логики

const CartSchema = z.object({
  items: z.array(
    z.object({
      price: z.number(),
      quantity: z.number(),
    })
  ),
}).superRefine((data, ctx) => {
  const total = data.items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );

  if (total <= 0) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "Некорректная сумма заказа",
    });
  }
});

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

При ошибке Zod возвращает подробный путь к полю.

const Schema = z.object({
  user: z.object({
    profile: z.object({
      age: z.number(),
    }),
  }),
});

Некорректные данные:

Schema.parse({
  user: {
    profile: {
      age: "18",
    },
  },
});

Ошибка:

[
  {
    code: "invalid_type",
    expected: "number",
    received: "string",
    path: ["user", "profile", "age"],
  }
]

safeParse для безопасной проверки

const result = Schema.safeParse(data);

if (!result.success) {
  console.log(result.error.format());
}

format() особенно полезен для вложенных структур:

{
  user: {
    profile: {
      age: {
        _errors: [
          "Expected number"
        ]
      }
    }
  }
}

transform внутри вложенных схем

const UserSchema = z.object({
  profile: z.object({
    firstName: z.string(),
    lastName: z.string(),
  }),
}).transform((data) => ({
  ...data,

  profile: {
    ...data.profile,
    fullName:
      `${data.profile.firstName} ${data.profile.lastName}`,
  },
}));

preprocess для вложенных данных

const Schema = z.object({
  settings: z.object({
    retries: z.preprocess(
      (value) => Number(value),
      z.number()
    ),
  }),
});

Строгие вложенные объекты

По умолчанию лишние поля удаляются.

const UserSchema = z.object({
  name: z.string(),
});
UserSchema.parse({
  name: "Alex",
  extra: true,
});

Результат:

{
  name: "Alex"
}

Для строгой проверки:

const StrictSchema = z
  .object({
    name: z.string(),
  })
  .strict();

Теперь лишние поля вызовут ошибку.


passthrough для сохранения неизвестных полей

const FlexibleSchema = z
  .object({
    name: z.string(),
  })
  .passthrough();

strip для удаления лишних полей

const StripSchema = z
  .object({
    name: z.string(),
  })
  .strip();

Вложенные tuple

const CoordinatesSchema = z.tuple([
  z.number(),
  z.number(),
]);

const LocationSchema = z.object({
  coordinates: CoordinatesSchema,
});

Set и Map внутри объектов

Set

const Schema = z.object({
  tags: z.set(z.string()),
});

Map

const Schema = z.object({
  dictionary: z.map(
    z.string(),
    z.number()
  ),
});

Вложенные enum

const RoleSchema = z.enum([
  "admin",
  "user",
  "guest",
]);

const UserSchema = z.object({
  role: RoleSchema,
});

Глубокая композиция схем

Крупные приложения обычно строятся через независимые схемы.

const GeoSchema = z.object({
  lat: z.number(),
  lng: z.number(),
});

const AddressSchema = z.object({
  city: z.string(),
  geo: GeoSchema,
});

const UserSchema = z.object({
  name: z.string(),
  address: AddressSchema,
});

const CompanySchema = z.object({
  title: z.string(),
  employees: z.array(UserSchema),
});

Такой подход:

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

Практический пример сложной структуры API

const ApiResponseSchema = z.object({
  success: z.boolean(),

  data: z.object({
    users: z.array(
      z.object({
        id: z.number(),

        profile: z.object({
          username: z.string(),
          email: z.string().email(),
        }),

        posts: z.array(
          z.object({
            id: z.number(),
            title: z.string(),

            comments: z.array(
              z.object({
                id: z.number(),
                text: z.string(),
              })
            ),
          })
        ),
      })
    ),
  }),

  meta: z.object({
    page: z.number(),
    total: z.number(),
  }),
});

Такие схемы особенно востребованы:

  • в REST API;
  • в GraphQL;
  • в формах;
  • в CMS;
  • в системах конфигурации;
  • в state management;
  • в серверной валидации;
  • в fullstack TypeScript-приложениях.