Fix: Zod Validation Not Working — safeParse Returns Wrong Error, transform Breaks Type, or discriminatedUnion Fails
Quick Answer
How to fix Zod schema validation issues — parse vs safeParse, transform and preprocess, refine for cross-field validation, discriminatedUnion, error formatting, and common schema mistakes.
The Problem
safeParse returns a success result for data that should fail:
const schema = z.object({
email: z.string().email(),
age: z.number().min(18),
});
const result = schema.safeParse({
email: "not-an-email", // Should fail
age: "25", // Wrong type — should fail
});
console.log(result.success); // true — why?Or transform makes the TypeScript type wrong:
const schema = z.string().transform(val => parseInt(val));
type Input = z.infer<typeof schema>; // string — expected numberOr discriminatedUnion doesn’t narrow the type in the union variant:
const schema = z.discriminatedUnion('type', [
z.object({ type: z.literal('cat'), meows: z.boolean() }),
z.object({ type: z.literal('dog'), barks: z.boolean() }),
]);
const result = schema.parse({ type: 'cat', meows: true });
result.meows; // TypeScript error: Property 'meows' does not existWhy This Happens
Zod validates data strictly, but common mistakes bypass the strictness:
z.string()doesn’t coerce numbers —"25"is a string, not a number.z.number()rejects it. Butz.coerce.number()would accept it. The two are easy to confuse.z.infer<typeof schema>gives the output type — after.transform(), the output type changes. Usez.input<typeof schema>for the pre-transform type.discriminatedUnionrequires accessing the discriminant first — after parsing, TypeScript knows the value matches the union but needs aswitchorifon the discriminant field before narrowing to a specific variant.refineruns after all field validations — if a field fails basic validation (e.g., wrong type),refinedoesn’t run. Cross-field validation inrefinemay not trigger when you expect.
Fix 1: Understand parse vs safeParse
import { z } from 'zod';
const UserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().min(18),
});
// parse — throws ZodError on failure
try {
const user = UserSchema.parse({ name: '', email: 'bad', age: 15 });
} catch (e) {
if (e instanceof z.ZodError) {
console.log(e.errors);
// [
// { path: ['name'], message: 'String must contain at least 1 character(s)' },
// { path: ['email'], message: 'Invalid email' },
// { path: ['age'], message: 'Number must be greater than or equal to 18' },
// ]
}
}
// safeParse — returns { success: true, data } or { success: false, error }
const result = UserSchema.safeParse({ name: '', email: 'bad', age: 15 });
if (!result.success) {
// Friendly error messages using flatten()
const errors = result.error.flatten();
console.log(errors.fieldErrors);
// { name: ['...'], email: ['...'], age: ['...'] }
// Or format() for nested errors
const formatted = result.error.format();
console.log(formatted.email?._errors); // ['Invalid email']
} else {
const user = result.data; // Fully typed
}Why data might pass unexpectedly — strip vs strict:
// Default: z.object() STRIPS unknown keys
const schema = z.object({ name: z.string() });
const result = schema.parse({ name: 'Alice', extra: 'ignored' });
// { name: 'Alice' } — extra field silently removed
// strict() — fails if unknown keys present
const strictSchema = z.object({ name: z.string() }).strict();
strictSchema.parse({ name: 'Alice', extra: 'ignored' });
// ZodError: Unrecognized key(s) in object: 'extra'
// passthrough() — keeps unknown keys in output
const passthroughSchema = z.object({ name: z.string() }).passthrough();
const out = passthroughSchema.parse({ name: 'Alice', extra: 'kept' });
// { name: 'Alice', extra: 'kept' }Fix 2: Use Coerce for String-to-Type Conversion
When accepting form data or query params (which are always strings), use z.coerce:
// WRONG — fails for string '25' even though it looks like a number
const schema = z.object({
age: z.number().min(18),
});
schema.parse({ age: '25' }); // ZodError: Expected number, received string
// CORRECT — coerce converts string to the target type
const formSchema = z.object({
age: z.coerce.number().int().min(18), // '25' → 25
active: z.coerce.boolean(), // 'true' → true, '1' → true
birthDate: z.coerce.date(), // '2000-01-01' → Date object
score: z.coerce.number().optional(), // '' → fails, undefined passes
});
formSchema.parse({ age: '25', active: 'true', birthDate: '2000-01-01' });
// { age: 25, active: true, birthDate: Date(...) }
// For query strings where empty string should be undefined
const querySchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20),
search: z.string().optional().transform(v => v || undefined),
});Fix 3: Transform and preprocess for Data Shaping
Use transform for output transformation, preprocess for input normalization:
// transform — changes the OUTPUT type
const trimmedString = z.string().transform(s => s.trim());
type Output = z.output<typeof trimmedString>; // string
type Input = z.input<typeof trimmedString>; // string (same for simple transform)
// Complex transform — output type changes
const dateString = z.string().transform(s => new Date(s));
type DateOutput = z.output<typeof dateString>; // Date
type DateInput = z.input<typeof dateString>; // string
// Use z.infer for OUTPUT type (what you get after parsing)
type Parsed = z.infer<typeof dateString>; // Date
// preprocess — runs BEFORE type checking
const flexibleNumber = z.preprocess(
(val) => (typeof val === 'string' ? parseFloat(val) : val),
z.number()
);
flexibleNumber.parse('3.14'); // 3.14 (number)
flexibleNumber.parse(3.14); // 3.14 (number)
// Real-world: normalize API input
const CreateUserSchema = z.object({
name: z.string().trim().min(1, 'Name is required'),
email: z.string().toLowerCase().email(),
phone: z.string()
.transform(p => p.replace(/[\s\-\(\)]/g, '')) // Remove formatting
.pipe(z.string().regex(/^\+?[0-9]{10,15}$/, 'Invalid phone')),
birthYear: z.coerce.number().int().min(1900).max(new Date().getFullYear()),
});pipe for chained validation after transform:
// Transform then validate the result
const csvToArray = z.string()
.transform(s => s.split(',').map(s => s.trim()))
.pipe(z.array(z.string().min(1)).min(1));
csvToArray.parse('a, b, c'); // ['a', 'b', 'c']
csvToArray.parse(''); // ZodError: Array must contain at least 1 element(s)Fix 4: Cross-Field Validation with refine and superRefine
// refine — single boolean check
const PasswordSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{
message: 'Passwords do not match',
path: ['confirmPassword'], // Which field to attach the error to
}
);
// Multiple refine calls — chain them
const DateRangeSchema = z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
}).refine(
(data) => data.endDate > data.startDate,
{ message: 'End date must be after start date', path: ['endDate'] }
).refine(
(data) => {
const diffDays = (data.endDate.getTime() - data.startDate.getTime()) / 86400000;
return diffDays <= 365;
},
{ message: 'Date range cannot exceed 1 year', path: ['endDate'] }
);
// superRefine — add multiple issues in one call
const RegistrationSchema = z.object({
username: z.string().min(3),
password: z.string().min(8),
confirmPassword: z.string(),
age: z.number().int(),
}).superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Passwords do not match',
path: ['confirmPassword'],
});
}
if (data.age < 18) {
ctx.addIssue({
code: z.ZodIssueCode.too_small,
minimum: 18,
type: 'number',
inclusive: true,
message: 'Must be 18 or older',
path: ['age'],
});
}
});Fix 5: discriminatedUnion for Narrowing
After parsing a discriminatedUnion, use a switch or if to narrow the type:
const EventSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('click'),
x: z.number(),
y: z.number(),
}),
z.object({
type: z.literal('keydown'),
key: z.string(),
ctrlKey: z.boolean(),
}),
z.object({
type: z.literal('scroll'),
deltaY: z.number(),
}),
]);
type Event = z.infer<typeof EventSchema>;
function handleEvent(raw: unknown) {
const event = EventSchema.parse(raw);
// WRONG — no narrowing, TypeScript doesn't know which variant
console.log(event.x); // Error: Property 'x' does not exist on type 'Event'
// CORRECT — narrow with switch on the discriminant
switch (event.type) {
case 'click':
console.log(event.x, event.y); // Fully typed
break;
case 'keydown':
console.log(event.key, event.ctrlKey);
break;
case 'scroll':
console.log(event.deltaY);
break;
}
}
// discriminatedUnion vs union — use discriminatedUnion when possible
// union: checks each schema in order (slow, poor errors)
const slowUnion = z.union([
z.object({ type: z.literal('a'), value: z.string() }),
z.object({ type: z.literal('b'), value: z.number() }),
]);
// discriminatedUnion: jumps directly to the right schema (fast, clear errors)
const fastUnion = z.discriminatedUnion('type', [
z.object({ type: z.literal('a'), value: z.string() }),
z.object({ type: z.literal('b'), value: z.number() }),
]);Fix 6: Practical Patterns for API Validation
// Reusable schema building blocks
const Id = z.string().uuid();
const Email = z.string().email().toLowerCase();
const Password = z.string().min(8).max(100);
const Pagination = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
// Compose schemas
const CreateUserInput = z.object({
name: z.string().trim().min(1).max(100),
email: Email,
password: Password,
role: z.enum(['user', 'admin']).default('user'),
});
const UpdateUserInput = CreateUserInput
.omit({ password: true })
.partial(); // All fields optional for PATCH
// Infer types from schemas
type CreateUserInput = z.infer<typeof CreateUserInput>;
type UpdateUserInput = z.infer<typeof UpdateUserInput>;
// Express/Fastify validation middleware
function validateBody<T extends z.ZodType>(schema: T) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.flatten().fieldErrors,
});
}
req.body = result.data; // Replace with parsed/transformed data
next();
};
}
app.post('/users', validateBody(CreateUserInput), async (req, res) => {
const input = req.body as CreateUserInput; // Or use req.validatedBody with typing
// ...
});
// React Hook Form integration
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
function RegisterForm() {
const form = useForm<CreateUserInput>({
resolver: zodResolver(CreateUserInput),
defaultValues: { role: 'user' },
});
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<input {...form.register('email')} />
{form.formState.errors.email && (
<span>{form.formState.errors.email.message}</span>
)}
</form>
);
}Still Not Working?
z.infer gives never for complex schemas — never usually means the schema has a contradiction (e.g., z.string().and(z.number())). Check for intersections (z.intersection) or z.and() calls that combine incompatible types. Each element of a z.discriminatedUnion must have the discriminant as a z.literal().
Optional fields vs nullable fields — these are distinct in Zod:
z.string().optional() // string | undefined — field can be absent
z.string().nullable() // string | null — field must be present, can be null
z.string().nullish() // string | null | undefined — both
// For API inputs where field might be absent OR null:
z.object({
bio: z.string().nullish(), // optional and nullable
})Async refine for database uniqueness checks — Zod supports async validation with .refine(async fn) and schema.parseAsync():
const UniqueEmailSchema = z.object({
email: z.string().email(),
}).refine(
async (data) => {
const existing = await db.users.findUnique({ where: { email: data.email } });
return !existing;
},
{ message: 'Email already in use', path: ['email'] }
);
// Must use parseAsync or safeParseAsync for async refinements
const result = await UniqueEmailSchema.safeParseAsync(formData);For related TypeScript issues, see Fix: TypeScript Property Does Not Exist on Type and Fix: tRPC Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: pdf-lib Not Working — PDF Not Generating, Fonts Not Embedding, or Pages Blank
How to fix pdf-lib issues — creating PDFs from scratch, modifying existing PDFs, embedding fonts and images, form filling, merging documents, and browser and Node.js usage.
Fix: Pusher Not Working — Events Not Received, Channel Auth Failing, or Connection Dropping
How to fix Pusher real-time issues — client and server setup, channel types, presence channels, authentication endpoints, event binding, connection management, and React integration.
Fix: date-fns Not Working — Wrong Timezone Output, Invalid Date, or Locale Not Applied
How to fix date-fns issues — timezone handling with date-fns-tz, parseISO vs new Date, locale import and configuration, DST edge cases, v3 ESM migration, and common format pattern mistakes.
Fix: TypeScript Function Overload Error — No Overload Matches This Call
How to fix TypeScript function overload errors — overload signature compatibility, implementation signature, conditional types as alternatives, method overloads in classes, and common pitfalls.