Дискриминированные объединения

Дискриминированное объединение (Discriminated Union) — это объединение нескольких схем объектов, где каждая схема содержит общее поле-дискриминатор. Значение этого поля позволяет однозначно определить структуру объекта.

В Zod для этого используется метод z.discriminatedUnion().

Главная задача такого подхода — безопасно описывать разные варианты одного типа данных.

Пример:

import { z } from "zod";

const SuccessResponse = z.object({
  status: z.literal("success"),
  data: z.string(),
});

const ErrorResponse = z.object({
  status: z.literal("error"),
  message: z.string(),
});

const ResponseSchema = z.discriminatedUnion("status", [
  SuccessResponse,
  ErrorResponse,
]);

Поле status выступает дискриминатором.

Возможные валидные данные:

{
  status: "success",
  data: "OK"
}
{
  status: "error",
  message: "Something went wrong"
}

Невалидный объект:

{
  status: "success",
  message: "Wrong structure"
}

Ошибка возникает потому, что для status: "success" ожидается поле data, а не message.


Отличие от обычного union

Обычное объединение создаётся через z.union():

const Schema = z.union([
  SuccessResponse,
  ErrorResponse,
]);

Такой подход работает, но имеет недостатки:

  1. Zod проверяет схемы последовательно.
  2. Ошибки становятся менее понятными.
  3. Производительность ниже.
  4. Невозможно быстро определить нужную ветку.

z.discriminatedUnion() решает эти проблемы.


Принцип работы

Метод принимает:

  1. Имя поля-дискриминатора.
  2. Массив схем объектов.
z.discriminatedUnion("type", [
  SchemaA,
  SchemaB,
  SchemaC,
]);

Каждая схема обязана:

  • быть z.object(...)
  • содержать поле type
  • использовать z.literal(...)

Пример:

const Cat = z.object({
  type: z.literal("cat"),
  lives: z.number(),
});

const Dog = z.object({
  type: z.literal("dog"),
  breed: z.string(),
});

const Animal = z.discriminatedUnion("type", [
  Cat,
  Dog,
]);

Базовая валидация

Успешная проверка

Animal.parse({
  type: "cat",
  lives: 9,
});

Результат:

{
  type: "cat",
  lives: 9
}

Ошибка неизвестного дискриминатора

Animal.parse({
  type: "bird",
});

Ошибка:

Invalid discriminator value. Expected 'cat' | 'dog'

Ошибка структуры

Animal.parse({
  type: "dog",
  lives: 9,
});

Ошибка возникает потому, что объект с type: "dog" обязан содержать breed.


Инференция типов TypeScript

Одно из главных преимуществ — корректное сужение типов.

type Animal = z.infer<typeof Animal>;

TypeScript автоматически понимает структуру:

function handleAnimal(animal: Animal) {
  if (animal.type === "cat") {
    console.log(animal.lives);
  }

  if (animal.type === "dog") {
    console.log(animal.breed);
  }
}

Без дополнительных проверок TypeScript знает:

  • у cat есть lives
  • у dog есть breed

Это важнейшее преимущество дискриминированных объединений.


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

Вместо строковых литералов часто применяются enum.

TypeScript enum

enum PaymentType {
  CARD = "card",
  CASH = "cash",
}

Схемы:

const CardPayment = z.object({
  type: z.literal(PaymentType.CARD),
  cardNumber: z.string(),
});

const CashPayment = z.object({
  type: z.literal(PaymentType.CASH),
  amount: z.number(),
});

const PaymentSchema = z.discriminatedUnion("type", [
  CardPayment,
  CashPayment,
]);

Работа с API-ответами

Дискриминированные объединения особенно полезны при описании серверных ответов.

Успешный ответ

const ApiSuccess = z.object({
  status: z.literal("success"),
  data: z.object({
    id: z.number(),
    name: z.string(),
  }),
});

Ошибка

const ApiError = z.object({
  status: z.literal("error"),
  error: z.string(),
});

Общая схема

const ApiResponse = z.discriminatedUnion("status", [
  ApiSuccess,
  ApiError,
]);

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

const response = ApiResponse.parse(data);

if (response.status === "success") {
  console.log(response.data.name);
} else {
  console.log(response.error);
}

Моделирование событий

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

Событие создания

const CreateEvent = z.object({
  event: z.literal("create"),
  payload: z.object({
    id: z.number(),
  }),
});

Событие удаления

const DeleteEvent = z.object({
  event: z.literal("delete"),
  payload: z.object({
    id: z.number(),
  }),
});

Объединение

const EventSchema = z.discriminatedUnion("event", [
  CreateEvent,
  DeleteEvent,
]);

Обработка сообщений WebSocket

const JoinMessage = z.object({
  type: z.literal("join"),
  username: z.string(),
});

const ChatMessage = z.object({
  type: z.literal("message"),
  text: z.string(),
});

const LeaveMessage = z.object({
  type: z.literal("leave"),
  username: z.string(),
});

const SocketMessage = z.discriminatedUnion("type", [
  JoinMessage,
  ChatMessage,
  LeaveMessage,
]);

Обработка сообщений

function handleMessage(data: unknown) {
  const message = SocketMessage.parse(data);

  switch (message.type) {
    case "join":
      console.log(message.username);
      break;

    case "message":
      console.log(message.text);
      break;

    case "leave":
      console.log(message.username);
      break;
  }
}

Nested discriminated unions

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

const Circle = z.object({
  shape: z.literal("circle"),
  radius: z.number(),
});

const Rectangle = z.object({
  shape: z.literal("rectangle"),
  width: z.number(),
  height: z.number(),
});
const Shape = z.discriminatedUnion("shape", [
  Circle,
  Rectangle,
]);

Следующий уровень:

const DrawableShape = z.object({
  type: z.literal("drawable"),
  shape: Shape,
});

const HiddenShape = z.object({
  type: z.literal("hidden"),
  shape: Shape,
});
const ShapeContainer = z.discriminatedUnion("type", [
  DrawableShape,
  HiddenShape,
]);

Расширение схем

Схемы можно расширять через .extend().

const BaseEvent = z.object({
  timestamp: z.number(),
});
const LoginEvent = BaseEvent.extend({
  type: z.literal("login"),
  user: z.string(),
});
const LogoutEvent = BaseEvent.extend({
  type: z.literal("logout"),
  user: z.string(),
});
const EventSchema = z.discriminatedUnion("type", [
  LoginEvent,
  LogoutEvent,
]);

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

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

const CreditCardPayment = z.object({
  type: z.literal("card"),
  cardNumber: z.string().refine(
    (value) => value.length === 16,
    {
      message: "Card number must contain 16 digits",
    }
  ),
});
const CashPayment = z.object({
  type: z.literal("cash"),
  amount: z.number().positive(),
});
const Payment = z.discriminatedUnion("type", [
  CreditCardPayment,
  CashPayment,
]);

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

superRefine() позволяет создавать сложную логику валидации.

const TransferPayment = z.object({
  type: z.literal("transfer"),
  from: z.string(),
  to: z.string(),
}).superRefine((data, ctx) => {
  if (data.from === data.to) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "Accounts must differ",
    });
  }
});

Optional поля

Ветви могут иметь разные optional-поля.

const EmailNotification = z.object({
  type: z.literal("email"),
  email: z.string().email(),
  subject: z.string().optional(),
});
const SmsNotification = z.object({
  type: z.literal("sms"),
  phone: z.string(),
});
const Notification = z.discriminatedUnion("type", [
  EmailNotification,
  SmsNotification,
]);

Nullable значения

const ImageBlock = z.object({
  type: z.literal("image"),
  url: z.string().nullable(),
});

const TextBlock = z.object({
  type: z.literal("text"),
  content: z.string(),
});

const ContentBlock = z.discriminatedUnion("type", [
  ImageBlock,
  TextBlock,
]);

SafeParse

Для безопасной обработки ошибок используется safeParse.

const result = Payment.safeParse(input);

Проверка:

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

Ошибки в discriminatedUnion

Пример:

const User = z.discriminatedUnion("role", [
  z.object({
    role: z.literal("admin"),
    permissions: z.array(z.string()),
  }),

  z.object({
    role: z.literal("user"),
    email: z.string().email(),
  }),
]);

Невалидные данные:

{
  role: "admin"
}

Ошибка:

Required at "permissions"

Ограничения discriminatedUnion

Все схемы должны содержать дискриминатор

Ошибка:

const A = z.object({
  type: z.literal("a"),
});

const B = z.object({
  value: z.string(),
});
z.discriminatedUnion("type", [A, B]);

B не содержит type.


Значения дискриминатора должны быть уникальными

Ошибка:

const A = z.object({
  type: z.literal("item"),
});

const B = z.object({
  type: z.literal("item"),
});

Одинаковые значения недопустимы.


Дискриминатор должен быть literal

Ошибка:

const A = z.object({
  type: z.string(),
});

Допустимо только:

type: z.literal("a")

discriminatedUnion и transform

Трансформации работают внутри отдельных ветвей.

const NumberValue = z.object({
  type: z.literal("number"),
  value: z.string().transform(Number),
});
const StringValue = z.object({
  type: z.literal("string"),
  value: z.string(),
});
const ValueSchema = z.discriminatedUnion("type", [
  NumberValue,
  StringValue,
]);

discriminatedUnion и preprocess

const DateEvent = z.object({
  type: z.literal("date"),
  value: z.preprocess(
    (v) => new Date(String(v)),
    z.date()
  ),
});

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

const Item = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("text"),
    value: z.string(),
  }),

  z.object({
    type: z.literal("number"),
    value: z.number(),
  }),
]);
const Items = z.array(Item);

Пример:

[
  {
    type: "text",
    value: "hello"
  },
  {
    type: "number",
    value: 42
  }
]

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

const Command = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("start"),
  }),

  z.object({
    type: z.literal("stop"),
  }),
]);
const CommandsMap = z.record(Command);

discriminatedUnion и lazy

Для рекурсивных структур применяется z.lazy().

const FileNode = z.object({
  type: z.literal("file"),
  name: z.string(),
});
const DirectoryNode: z.ZodType<any> = z.lazy(() =>
  z.object({
    type: z.literal("directory"),
    name: z.string(),
    children: z.array(Node),
  })
);
const Node = z.discriminatedUnion("type", [
  FileNode,
  DirectoryNode,
]);

Практический пример: редактор блоков

const ParagraphBlock = z.object({
  type: z.literal("paragraph"),
  text: z.string(),
});
const ImageBlock = z.object({
  type: z.literal("image"),
  src: z.string().url(),
  alt: z.string(),
});
const VideoBlock = z.object({
  type: z.literal("video"),
  url: z.string().url(),
  autoplay: z.boolean(),
});
const BlockSchema = z.discriminatedUnion("type", [
  ParagraphBlock,
  ImageBlock,
  VideoBlock,
]);
const DocumentSchema = z.array(BlockSchema);

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

discriminatedUnion быстрее обычного union.

Причина:

  1. Сначала читается дискриминатор.
  2. Выбирается нужная ветка.
  3. Валидируется только одна схема.

В union проверяются все варианты подряд.

Особенно заметна разница при:

  • большом количестве ветвей
  • сложных схемах
  • глубокой вложенности
  • обработке больших массивов

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

Подход особенно полезен для:

  • API-ответов
  • WebSocket-сообщений
  • событийной архитектуры
  • Redux action
  • state machine
  • CMS-блоков
  • AST-структур
  • форм с несколькими режимами
  • редакторов контента
  • JSON-протоколов
  • CLI-команд

Когда лучше использовать обычный union

z.union() предпочтительнее, если:

  • отсутствует общее поле-дискриминатор
  • объединяются примитивы
  • схемы невозможно разделить по одному полю

Пример:

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

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

Возможность union discriminatedUnion
Быстрая проверка Нет Да
Понятные ошибки Частично Да
Автоматическое сужение типов Ограничено Отлично
Требуется дискриминатор Нет Да
Подходит для API Частично Да
Подходит для сложных объектов Частично Да