Skip to content

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

FixDevs · (Updated: )

Part of:  React & Frontend Errors

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 is a runtime validator that doubles as a compile-time type generator — that dual role is the root cause of most “Zod isn’t working” reports. At runtime Zod sees the actual data, which can be any shape. At compile time TypeScript sees the schema, from which it infers the static type. When those two go out of sync — usually because a .transform() changes the output shape, or because input is a string where the schema expects a number — the runtime behavior surprises you even though the code type-checked cleanly.

Four mental-model fixes that resolve the most common reports:

  • z.string() doesn’t coerce numbers. Zod treats "25" as a string. z.number() rejects it with a clear Expected number, received string message. Use z.coerce.number() if you want the conversion to happen at the schema boundary — this is the right choice for HTML form data and URL query parameters, both of which arrive as strings even when they look like numbers.
  • z.infer<typeof schema> returns the OUTPUT type, not the input. After .transform(s => new Date(s)), the output is Date but the input is still string. If you care about either side specifically, use z.input<typeof schema> for the pre-transform shape or z.output<typeof schema> for the post-transform shape. z.infer is an alias for z.output, which is what most code wants — the parsed result.
  • discriminatedUnion narrows only after the discriminant is checked. Zod’s parser figures out which variant matched, but TypeScript still sees the whole union until you write switch (event.type) or if (event.type === 'click'). This is how TypeScript’s discriminated unions work everywhere — Zod just happens to expose them at runtime.
  • refine runs after all field validations succeed. If a field has the wrong type, refine never sees the data because parsing has already failed. Cross-field validation like “endDate must be after startDate” only triggers if both fields parsed successfully on their own. Use superRefine if you need to add issues conditionally based on a partial parse.

A practical implication of the runtime/compile-time split: never trust a TypeScript type for data crossing a system boundary. Every API response, every form submission, every database row read into your code should pass through safeParse before you treat it as the inferred type. The whole reason Zod exists is that the TypeScript types only describe what the code expects — Zod is what enforces that the data actually matches.

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?

Performance with deeply nested or recursive schemas. Zod parses by walking the schema once per input, which is fast for normal cases but can be noticeable on hot paths (e.g., parsing every incoming WebSocket message). If profiling shows Zod as a hot spot, consider parsing once per request boundary rather than per field access, and avoid z.lazy() recursion in critical paths — recursive schemas are correct but slow because they cannot be specialized at schema-construction time.

Bundle size matters in the browser. Zod ships around 13 KB gzipped, which is fine for most apps but heavy for an edge function or a tiny widget. For very size-sensitive code, alternatives like Valibot (modular, much smaller) or hand-written validators may be a better fit. Zod’s strength is the breadth of its API and its type inference, not its footprint.

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