Дискриминированное объединение (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.
Обычное объединение создаётся через z.union():
const Schema = z.union([
SuccessResponse,
ErrorResponse,
]);
Такой подход работает, но имеет недостатки:
z.discriminatedUnion() решает эти проблемы.
Метод принимает:
z.discriminatedUnion("type", [
SchemaA,
SchemaB,
SchemaC,
]);
Каждая схема обязана:
z.object(...)typez.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.
Одно из главных преимуществ — корректное сужение типов.
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 есть livesdog есть breedЭто важнейшее преимущество дискриминированных объединений.
Вместо строковых литералов часто применяются 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,
]);
Дискриминированные объединения особенно полезны при описании серверных ответов.
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,
]);
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;
}
}
Дискриминированные объединения можно вкладывать друг в друга.
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,
]);
Внутри отдельных веток можно использовать дополнительные проверки.
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() позволяет создавать сложную логику
валидации.
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-поля.
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,
]);
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.
const result = Payment.safeParse(input);
Проверка:
if (!result.success) {
console.log(result.error);
} else {
console.log(result.data);
}
Пример:
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"
Ошибка:
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"),
});
Одинаковые значения недопустимы.
Ошибка:
const A = z.object({
type: z.string(),
});
Допустимо только:
type: z.literal("a")
Трансформации работают внутри отдельных ветвей.
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,
]);
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
}
]
const Command = z.discriminatedUnion("type", [
z.object({
type: z.literal("start"),
}),
z.object({
type: z.literal("stop"),
}),
]);
const CommandsMap = z.record(Command);
Для рекурсивных структур применяется 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.
Причина:
В union проверяются все варианты подряд.
Особенно заметна разница при:
Подход особенно полезен для:
z.union() предпочтительнее, если:
Пример:
const Primitive = z.union([
z.string(),
z.number(),
z.boolean(),
]);
| Возможность | union | discriminatedUnion |
|---|---|---|
| Быстрая проверка | Нет | Да |
| Понятные ошибки | Частично | Да |
| Автоматическое сужение типов | Ограничено | Отлично |
| Требуется дискриминатор | Нет | Да |
| Подходит для API | Частично | Да |
| Подходит для сложных объектов | Частично | Да |