Fixing Zod Discriminated Union Mismatches
Zod 3.23’s z.discriminatedUnion fails with "Invalid discriminator value" or silently resolves to the wrong member when the discriminator field carries a transform, a default, or a non-literal type. This guide is part of the Runtime Validation with Zod cluster and provides a step-by-step diagnosis and fix for every common failure pattern.
Symptom: Exact ZodError Output
When z.discriminatedUnion cannot match the incoming payload to a member, Zod 3.23 produces one of the following error shapes from .safeParse():
ZodError: [
{
"code": "invalid_union_discriminator",
"options": ["email", "sms", "push"],
"path": ["type"],
"message": "Invalid discriminator value. Expected 'email' | 'sms' | 'push'"
}
]
A second, harder-to-spot failure is silent wrong-member selection: .safeParse() returns success: true but result.data carries the shape of the wrong variant, stripping fields that belong to the intended member. This occurs when two members share an overlapping literal set or when a .default() changes the discriminator value before member selection runs.
A third failure appears with nested unions: the outer discriminatedUnion resolves correctly but an inner discriminatedUnion on a sub-field emits:
ZodError: [
{
"code": "invalid_union",
"unionErrors": [...],
"path": ["payload"],
"message": "Invalid input"
}
]
Root Cause: How z.discriminatedUnion Resolves vs z.union
z.union tries each member schema in declaration order, accumulates all errors, and returns a combined ZodError if every member fails. It is O(n) in the number of members and produces error messages that mention every member simultaneously — making diagnostic output nearly unreadable for unions with more than three variants.
z.discriminatedUnion takes a different approach: it builds a lookup table keyed on the literal values of the discriminator field at schema construction time. At parse time it reads input[discriminatorKey], looks up the table entry, and runs only that member’s parser. This is O(1) and generates a single, targeted error. The lookup happens against the raw input value, before any .transform() or .default() on the discriminator field runs.
This has four concrete consequences that produce the errors above:
-
Non-literal discriminator types. If a member declares
type: z.string()instead oftype: z.literal("email"), Zod cannot build the lookup table and throws a schema construction error:"The discriminator value for the key "type" must be a literal value". -
Transforms on the discriminator key. If a member declares
type: z.string().transform(s => s.toLowerCase()), the raw input"Email"does not match the lookup key"email", producinginvalid_union_discriminator. -
.default()on the discriminator key.z.literal("email").default("email")wraps the literal in aZodDefaultobject. Zod’s lookup table extractor in 3.23 does not unwrapZodDefault, so the member is effectively invisible to the dispatcher. -
Missing discriminator key. If the input payload omits
typeentirely, the raw lookup value isundefined, which matches no literal, emittinginvalid_union_discriminatorwith the full options list.
Step-by-Step Fix
Step 1: Confirm the Discriminator Field Path
Run .safeParse() explicitly and log the full error to find the path and options in the invalid_union_discriminator issue:
import { z } from 'zod'; // zod 3.23
const NotificationSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('email'), to: z.string().email() }),
z.object({ type: z.literal('sms'), to: z.string().regex(/^\+\d{7,15}$/) }),
z.object({ type: z.literal('push'), deviceId: z.string().uuid() }),
]);
const result = NotificationSchema.safeParse({ type: 'Email', to: 'user@example.com' });
if (!result.success) {
console.error(JSON.stringify(result.error.issues, null, 2));
// Output: code: "invalid_union_discriminator", options: ["email","sms","push"], path: ["type"]
}
The options array tells you every literal the union knows about. If your expected value is absent from that list, the member schema is malformed (Step 2). If the value is present but the raw input does not match (wrong casing, trailing space), move to Step 3.
Why this works: safeParse never throws — it captures the full ZodError tree, including the discriminator issue’s options list, which reflects the exact lookup table built at schema-construction time.
Step 2: Ensure Every Member Uses z.literal on the Discriminator Key
Replace any z.string(), z.enum(), or z.nativeEnum() discriminator declarations with explicit z.literal:
// BEFORE — causes schema construction error in Zod 3.23
const BadSchema = z.discriminatedUnion('type', [
z.object({ type: z.string(), to: z.string() }), // ← z.string() not allowed
]);
// AFTER — every member uses z.literal
const FixedSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('email'), to: z.string().email() }),
z.object({ type: z.literal('sms'), to: z.string().regex(/^\+\d{7,15}$/) }),
z.object({ type: z.literal('push'), deviceId: z.string().uuid() }),
]);
If the discriminator field is an enum sourced from a shared constant object, derive each literal explicitly:
const NOTIFICATION_TYPES = ['email', 'sms', 'push'] as const;
// Extract individual literals from the const tuple
const [EMAIL, SMS, PUSH] = NOTIFICATION_TYPES.map(
(t) => z.literal(t)
) as [z.ZodLiteral<'email'>, z.ZodLiteral<'sms'>, z.ZodLiteral<'push'>];
const NotificationSchema = z.discriminatedUnion('type', [
z.object({ type: EMAIL, to: z.string().email() }),
z.object({ type: SMS, to: z.string().regex(/^\+\d{7,15}$/) }),
z.object({ type: PUSH, deviceId: z.string().uuid() }),
]);
Why this works: Zod’s lookup table extractor calls schema._def.value on each member’s discriminator field. ZodLiteral exposes _def.value directly. ZodString and ZodEnum do not, so Zod rejects them at construction time.
Step 3: Remove Transforms and Defaults from the Discriminator Field
If the discriminator field must accept a wider input (e.g., case-insensitive strings from an external API), pre-process the input before handing it to discriminatedUnion, rather than embedding a .transform() inside the union member.
// BEFORE — transform on discriminator causes invalid_union_discriminator
const BrokenSchema = z.discriminatedUnion('type', [
z.object({
type: z.string().toLowerCase().pipe(z.literal('email')), // ← transform hides literal
to: z.string().email(),
}),
]);
// AFTER — normalise the discriminator upstream, then parse the clean object
const RawInputSchema = z.object({
type: z.string().transform((s) => s.toLowerCase()),
}).passthrough(); // keeps all other fields
const NormalisedSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('email'), to: z.string().email() }),
z.object({ type: z.literal('sms'), to: z.string().regex(/^\+\d{7,15}$/) }),
z.object({ type: z.literal('push'), deviceId: z.string().uuid() }),
]);
function parseNotification(raw: unknown) {
// Step 1: normalise the discriminator
const normalised = RawInputSchema.safeParse(raw);
if (!normalised.success) return normalised;
// Step 2: dispatch to the correct member
return NormalisedSchema.safeParse(normalised.data);
}
The same two-pass approach fixes .default() on the discriminator field. Move the default into the calling code or into the RawInputSchema layer:
// BEFORE — ZodDefault wraps ZodLiteral, breaking lookup table
z.object({ type: z.literal('email').default('email'), to: z.string().email() })
// AFTER — default lives outside the union member
const input = { to: 'user@example.com' };
const withDefault = { type: 'email', ...input }; // apply default before safeParse
const result = NormalisedSchema.safeParse(withDefault);
Why this works: Zod’s discriminator extractor calls getDiscriminator() on the field schema at construction time. It unwraps ZodOptional and ZodNullable but not ZodDefault or ZodPipe in Zod 3.23. Keeping the discriminator field as a bare z.literal guarantees the extractor finds the value.
Step 4: Handle Nested and Conditional Discriminators with z.union
When the discriminator is not a top-level key — for example, payload.kind — or when two discriminators are needed simultaneously, z.discriminatedUnion cannot help. Switch to z.union with manual member ordering and add a .superRefine() for actionable error messages:
const EmailPayload = z.object({
type: z.literal('notification'),
payload: z.object({ kind: z.literal('email'), to: z.string().email() }),
});
const SmsPayload = z.object({
type: z.literal('notification'),
payload: z.object({ kind: z.literal('sms'), to: z.string() }),
});
// z.union tries members in order; put the most common variant first
const NotificationSchema = z.union([EmailPayload, SmsPayload]);
// Add a superRefine for targeted error messages when all members fail
const NotificationSchemaStrict = z.unknown().superRefine((val, ctx) => {
const result = NotificationSchema.safeParse(val);
if (!result.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Unknown notification kind. Received: ${
(val as Record<string, unknown>)?.payload?.kind ?? 'missing'
}`,
});
}
}).pipe(NotificationSchema);
For validating query params and environment variables that carry discriminated shapes, the same two-pass pattern applies — see Validating Query Params and Env Vars with Zod for the exact coercion layer needed before discriminator resolution.
Before / After Comparison
// ── BEFORE (Zod 3.23 — fails with invalid_union_discriminator) ──────────────
const BadNotificationSchema = z.discriminatedUnion('type', [
z.object({
type: z.string().transform(s => s.toLowerCase()).pipe(z.literal('email')),
to: z.string().email(),
}),
z.object({
type: z.literal('sms').default('sms'), // ZodDefault breaks lookup
to: z.string(),
}),
]);
BadNotificationSchema.safeParse({ type: 'EMAIL', to: 'user@example.com' });
// → { success: false, error: ZodError [invalid_union_discriminator] }
// ── AFTER (normalise upstream, bare literals in union members) ────────────────
const NormaliseType = z
.object({ type: z.string().transform(s => s.toLowerCase()) })
.passthrough();
const NotificationSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('email'), to: z.string().email() }),
z.object({ type: z.literal('sms'), to: z.string().regex(/^\+\d{7,15}$/) }),
z.object({ type: z.literal('push'), deviceId: z.string().uuid() }),
]);
function parse(raw: unknown) {
const norm = NormaliseType.safeParse(raw);
return norm.success ? NotificationSchema.safeParse(norm.data) : norm;
}
parse({ type: 'EMAIL', to: 'user@example.com' });
// → { success: true, data: { type: 'email', to: 'user@example.com' } }
Verification
Run the following Vitest suite to confirm all four failure modes are resolved:
import { describe, it, expect } from 'vitest';
import { parse } from './notification'; // the two-pass helper above
describe('discriminatedUnion fix', () => {
it('resolves email member on exact match', () => {
const r = parse({ type: 'email', to: 'a@b.com' });
expect(r.success).toBe(true);
if (r.success) expect(r.data.type).toBe('email');
});
it('resolves email member case-insensitively after normalisation', () => {
const r = parse({ type: 'EMAIL', to: 'a@b.com' });
expect(r.success).toBe(true);
});
it('rejects unknown discriminator values with targeted error', () => {
const r = parse({ type: 'fax', to: '555-0100' });
expect(r.success).toBe(false);
if (!r.success) {
expect(r.error.issues[0].code).toBe('invalid_union_discriminator');
expect(r.error.issues[0].path).toEqual(['type']);
}
});
it('rejects missing discriminator key', () => {
const r = parse({ to: 'a@b.com' });
expect(r.success).toBe(false);
});
it('does not cross-contaminate sms and push members', () => {
const r = parse({ type: 'push', deviceId: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11' });
expect(r.success).toBe(true);
if (r.success) expect(r.data).not.toHaveProperty('to');
});
});
Run with:
npx vitest run --reporter=verbose notification.test.ts
Expected output:
✓ discriminatedUnion fix > resolves email member on exact match
✓ discriminatedUnion fix > resolves email member case-insensitively after normalisation
✓ discriminatedUnion fix > rejects unknown discriminator values with targeted error
✓ discriminatedUnion fix > rejects missing discriminator key
✓ discriminatedUnion fix > does not cross-contaminate sms and push members
Test Files 1 passed (1)
Tests 5 passed (5)
For broader middleware integration, Implementing Zod Validation in Express.js Routes shows how to wire the two-pass helper into Express middleware so the normalisation step runs before discriminator resolution on every request.
When comparing library tradeoffs, consult Zod vs Joi vs Yup Performance Benchmarks — discriminated unions are one of the areas where Zod’s O(1) dispatch provides the largest throughput advantage over Joi’s sequential .alternatives() evaluation.
Edge Cases and Caveats
-
Union member order still matters for error messages. Even though
z.discriminatedUnionuses a lookup table for member selection, when the discriminator key matches but the member’s other fields fail, Zod reports errors only from that single member. This is actually better thanz.unionwhich concatenates all member errors, but it means the member schemas must be complete — a partial schema that passes discriminator lookup but lacks required fields will produce confusing errors about the missing fields, not about the discriminator. -
Zod 3.23 does not unwrap
ZodPipeon the discriminator field. If you use.pipe()to chain a literal, the lookup table extractor sees theZodPipewrapper and silently excludes the member, producinginvalid_union_discriminatorat runtime. The fix in Step 3 (normalise upstream, bare literal inside the union) is the only safe approach in 3.23. -
TypeScript inference narrows on the discriminator field automatically. After a successful
.safeParse(), theresult.datatype is a discriminated union type matching the TypeScript|union of all members. Accessingresult.data.todirectly (without narrowing onresult.data.type) triggers a TypeScript error iftois not present on every member — this is correct and intentional. Useif (result.data.type === 'email') { result.data.to }to get a narrowed type.
Frequently Asked Questions
Does z.discriminatedUnion throw “Invalid discriminator value” when a field uses z.string() instead of z.literal()?
No — it throws a schema construction error at the point where z.discriminatedUnion(...) is called, not at parse time. The message is: "The discriminator value for the key 'type' must be a literal value". This makes the bug surface immediately in tests, before any request hits the endpoint.
Can I use z.discriminatedUnion with TypeScript const enum values?
Not directly. const enum values are erased at compile time. Map each enum member to a z.literal() explicitly, or use z.nativeEnum() in a separate schema and validate the discriminator field independently before routing to the union.
What happens if two members share the same literal value for the discriminator?
Zod 3.23 throws at construction time: "Discriminator value 'email' appears in more than one member". Fix by ensuring every literal value is unique within a single z.discriminatedUnion call.
Why does removing .default() from the discriminator field break my TypeScript types?
Because ZodDefault changes the inferred input type to make the field optional. When you remove .default(), TypeScript correctly marks the field as required. Supply the default before calling safeParse (see Step 3) rather than inside the schema.
Is there a Zod version that fixes the ZodDefault and ZodPipe unwrapping limitation?
Zod 4 (beta as of early 2026) rewrites the discriminator extractor to unwrap ZodDefault, ZodOptional, ZodNullable, and ZodPipe. If you can migrate to Zod 4, the two-pass workaround in Step 3 is no longer required. Until then, treat the discriminator field as a bare z.literal with no wrapping modifiers.