Объединения типов (union)

Объединение типов (union) позволяет описывать значения, которые могут соответствовать нескольким схемам одновременно. Такой механизм особенно полезен при работе:

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

В основе лежит идея: значение считается валидным, если оно прошло проверку хотя бы одной схемы.

import { z } from "zod";

const schema = z.union([
  z.string(),
  z.number(),
]);

schema.parse("hello"); // OK
schema.parse(42);      // OK
schema.parse(true);    // Ошибка

Аналогичная сокращённая запись:

const schema = z.string().or(z.number());

Как работает union

z.union() принимает массив схем:

z.union([schema1, schema2, schema3]);

Во время валидации Zod последовательно проверяет каждую схему.

Если одна из схем подошла — проверка завершается успешно.

Если не подошла ни одна — выбрасывается ошибка.

const valueSchema = z.union([
  z.string(),
  z.boolean(),
  z.number(),
]);

valueSchema.parse(false);
valueSchema.parse("text");
valueSchema.parse(100);

Объединение примитивов

Наиболее распространённый сценарий — объединение примитивных типов.

Строка или число

const idSchema = z.union([
  z.string(),
  z.number(),
]);

Полезно при работе с идентификаторами:

idSchema.parse("123");
idSchema.parse(123);

Boolean или null

const flagSchema = z.union([
  z.boolean(),
  z.null(),
]);

Несколько литералов

const roleSchema = z.union([
  z.literal("admin"),
  z.literal("user"),
  z.literal("guest"),
]);

Проверка:

roleSchema.parse("admin"); // OK
roleSchema.parse("root");  // Ошибка

Использование z.enum вместо union

Иногда вместо объединения литералов лучше использовать z.enum().

Через union

const statusSchema = z.union([
  z.literal("success"),
  z.literal("error"),
  z.literal("loading"),
]);

Через enum

const statusSchema = z.enum([
  "success",
  "error",
  "loading",
]);

z.enum():

  • компактнее;
  • удобнее;
  • лучше читается;
  • предоставляет автодополнение.

Union объектов

Одно из важнейших применений — объединение объектных схем.

const catSchema = z.object({
  type: z.literal("cat"),
  meow: z.boolean(),
});

const dogSchema = z.object({
  type: z.literal("dog"),
  bark: z.boolean(),
});

const petSchema = z.union([
  catSchema,
  dogSchema,
]);

Проверка:

petSchema.parse({
  type: "cat",
  meow: true,
});

petSchema.parse({
  type: "dog",
  bark: false,
});

Ошибка:

petSchema.parse({
  type: "cat",
  bark: true,
});

Вывод типов TypeScript

Zod автоматически формирует объединённый TypeScript-тип.

type Pet = z.infer<typeof petSchema>;

Результат:

type Pet =
  | {
      type: "cat";
      meow: boolean;
    }
  | {
      type: "dog";
      bark: boolean;
    };

Narrowing после union

TypeScript умеет автоматически сужать типы.

function handlePet(pet: Pet) {
  if (pet.type === "cat") {
    console.log(pet.meow);
  }

  if (pet.type === "dog") {
    console.log(pet.bark);
  }
}

Это один из главных плюсов использования discriminated union.


Discriminated Union

Проблема обычного union

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

z.union([
  schema1,
  schema2,
  schema3,
  schema4,
]);

Каждая схема валидируется по очереди.


Решение — discriminatedUnion

z.discriminatedUnion() использует специальное поле-дискриминатор.

const resultSchema = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("success"),
    data: z.string(),
  }),

  z.object({
    type: z.literal("error"),
    message: z.string(),
  }),
]);

Теперь Zod:

  1. смотрит на поле type;
  2. сразу выбирает нужную схему;
  3. валидирует только её.

Пример discriminated union

const paymentSchema = z.discriminatedUnion("method", [
  z.object({
    method: z.literal("card"),
    cardNumber: z.string(),
  }),

  z.object({
    method: z.literal("paypal"),
    email: z.string().email(),
  }),

  z.object({
    method: z.literal("crypto"),
    wallet: z.string(),
  }),
]);

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

paymentSchema.parse({
  method: "paypal",
  email: "user@example.com",
});

Преимущества discriminatedUnion

Производительность

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


Более понятные ошибки

Ошибки становятся точнее.

Обычный union:

Invalid input

Discriminated union:

Required field "email"

Удобство в TypeScript

TypeScript лучше сужает типы.

type Payment = z.infer<typeof paymentSchema>;

function process(payment: Payment) {
  switch (payment.method) {
    case "card":
      payment.cardNumber;
      break;

    case "paypal":
      payment.email;
      break;

    case "crypto":
      payment.wallet;
      break;
  }
}

Union и optional

Иногда разработчики вручную создают объединение с undefined.

const schema = z.union([
  z.string(),
  z.undefined(),
]);

Но правильнее использовать:

const schema = z.string().optional();

Union и nullable

Аналогично для null.

Через union

const schema = z.union([
  z.string(),
  z.null(),
]);

Через nullable

const schema = z.string().nullable();

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

const schema = z.string().nullable().optional();

Тип:

string | null | undefined

Union массивов

Массив строк или массив чисел

const schema = z.union([
  z.array(z.string()),
  z.array(z.number()),
]);

Допустимые значения:

["a", "b", "c"]
[1, 2, 3]

Недопустимое значение:

["a", 1]

Union внутри массива

Массив смешанных значений

const schema = z.array(
  z.union([
    z.string(),
    z.number(),
  ])
);

Теперь допустимо:

["a", 1, "b", 2]

Вложенные union

Union можно вкладывать друг в друга.

const schema = z.union([
  z.string(),

  z.union([
    z.number(),
    z.boolean(),
  ]),
]);

Но такой код ухудшает читаемость.

Лучше:

const schema = z.union([
  z.string(),
  z.number(),
  z.boolean(),
]);

Union и transform

Объединения хорошо работают с трансформациями.

const schema = z.union([
  z.string(),
  z.number(),
]).transform((value) => String(value));

Теперь:

schema.parse(100); // "100"
schema.parse("abc"); // "abc"

Разные transform для разных веток

const schema = z.union([
  z.string().transform((v) => v.toUpperCase()),
  z.number().transform((v) => v * 2),
]);

Результат:

schema.parse("hello"); // "HELLO"
schema.parse(5);       // 10

Union и refine

Дополнительные проверки можно применять ко всему объединению.

const schema = z.union([
  z.string(),
  z.number(),
]).refine((value) => {
  return String(value).length >= 3;
});

Проверка сложных условий

const schema = z.union([
  z.string(),
  z.number(),
]).refine((value) => {
  if (typeof value === "string") {
    return value.startsWith("A");
  }

  return value > 100;
});

Union и preprocess

Предварительная обработка часто используется вместе с union.

const schema = z.preprocess(
  (value) => {
    if (typeof value === "string" && !isNaN(Number(value))) {
      return Number(value);
    }

    return value;
  },

  z.union([
    z.string(),
    z.number(),
  ])
);

Обработка ошибок union

Ошибки union бывают объёмными, потому что Zod хранит информацию обо всех неудачных проверках.

const schema = z.union([
  z.string(),
  z.number(),
]);

const result = schema.safeParse(true);

console.log(result.error);

safeParse вместо parse

parse() выбрасывает исключение.

schema.parse(value);

safeParse() возвращает объект результата.

const result = schema.safeParse(value);

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

Формат ошибок union

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

  z.object({
    type: z.literal("b"),
    count: z.number(),
  }),
]);

Ошибка:

schema.safeParse({
  type: "a",
  count: 5,
});

Zod покажет:

  • почему не подошла первая схема;
  • почему не подошла вторая схема.

Производительность union

Обычный union

z.union([
  schema1,
  schema2,
  schema3,
  schema4,
]);

Каждая схема проверяется по очереди.


Discriminated union

z.discriminatedUnion("type", [...]);

Используется прямой выбор ветки.

При большом количестве вариантов discriminatedUnion значительно эффективнее.


Когда использовать union

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

  • альтернативных структур данных;
  • нескольких форматов API;
  • параметров разных типов;
  • polymorphic data;
  • различных режимов работы формы;
  • обработки событий;
  • DTO-моделей.

Когда лучше избегать union

Проблемные сценарии

Слишком похожие схемы

z.union([
  z.object({
    value: z.string(),
  }),

  z.object({
    value: z.string(),
    extra: z.boolean(),
  }),
]);

Такие схемы могут вызывать неоднозначность.


Слишком большие union

z.union([
  schema1,
  schema2,
  schema3,
  schema4,
  schema5,
  schema6,
  schema7,
]);

При большом количестве вариантов:

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

Практический пример API-ответа

const apiResponseSchema = z.discriminatedUnion("status", [
  z.object({
    status: z.literal("success"),
    data: z.object({
      users: z.array(z.string()),
    }),
  }),

  z.object({
    status: z.literal("error"),
    error: z.string(),
  }),

  z.object({
    status: z.literal("loading"),
  }),
]);

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

const response = apiResponseSchema.parse(data);

switch (response.status) {
  case "success":
    console.log(response.data.users);
    break;

  case "error":
    console.log(response.error);
    break;

  case "loading":
    console.log("Loading...");
    break;
}

Практический пример формы авторизации

const authSchema = z.discriminatedUnion("provider", [
  z.object({
    provider: z.literal("email"),
    email: z.string().email(),
    password: z.string(),
  }),

  z.object({
    provider: z.literal("google"),
    token: z.string(),
  }),

  z.object({
    provider: z.literal("github"),
    accessToken: z.string(),
  }),
]);

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

const eventSchema = z.discriminatedUnion("event", [
  z.object({
    event: z.literal("click"),
    x: z.number(),
    y: z.number(),
  }),

  z.object({
    event: z.literal("scroll"),
    position: z.number(),
  }),

  z.object({
    event: z.literal("keydown"),
    key: z.string(),
  }),
]);

Отличия union от intersection

Union

Объединяет альтернативы.

A | B
z.union([A, B])

Значение должно соответствовать хотя бы одной схеме.


Intersection

Объединяет требования.

A & B
z.intersection(A, B)

Значение обязано соответствовать обеим схемам одновременно.


Сравнение union и any

Union

z.union([
  z.string(),
  z.number(),
]);

Строго ограничивает набор типов.


Any

z.any()

Разрешает абсолютно всё.


Сравнение union и unknown

Unknown

z.unknown()

Разрешает любое значение, но требует дальнейшей проверки.


Union

z.union([
  z.string(),
  z.number(),
]);

Сразу задаёт допустимые варианты.


Рекомендации по использованию

Использовать discriminatedUnion для объектов

Предпочтительно:

z.discriminatedUnion("type", [...]);

Избегать слишком больших union

При большом количестве веток лучше пересмотреть архитектуру схем.


Использовать литералы-дискриминаторы

Хороший вариант:

type: z.literal("success")

Плохой вариант:

type: z.string()

Не злоупотреблять вложенностью

Слишком глубокие union сложно поддерживать.


Использовать enum там, где это возможно

z.enum(["a", "b", "c"])

обычно лучше, чем:

z.union([
  z.literal("a"),
  z.literal("b"),
  z.literal("c"),
]);