Skip to content

Fix: Zod Validation Not Working — safeParse Returns Wrong Error, transform Breaks Type, or discriminatedUnion Fails

FixDevs ·

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 number

Or 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 exist

Why 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. But z.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. Use z.input<typeof schema> for the pre-transform type.
  • discriminatedUnion requires accessing the discriminant first — after parsing, TypeScript knows the value matches the union but needs a switch or if on the discriminant field before narrowing to a specific variant.
  • refine runs after all field validations — if a field fails basic validation (e.g., wrong type), refine doesn’t run. Cross-field validation in refine may 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 schemasnever 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles