zod: refine() function gets called even when parser already failed within translate()/refine()/regex()

This is very similar to this one: https://github.com/colinhacks/zod/issues/2113

Happening in version 3.21.4

refine() function still runs even though a previous translate has already invalidated it. It’s getting passed some aribtrary object instead of the string that it expects.

const parseResult = z
	.unknown()
	.transform((x, ctx) => {
		if (typeof x === 'string') return x;

		console.log('I fail because input is a number (correct behavior)');
		ctx.addIssue({
			code: 'custom',
		});
		return z.NEVER;
	})
	.transform((x: string) => {
		console.log("I don't get executed (correct behavior since transform above invalidated the input)");
		return x;
	})
	.refine((x: string) => {
		//BUG
		console.log("I shouldn't get called but I do!!!!");
		console.log(`I should be a string but I am a ${typeof x}!!!`); //'object'
		console.log(x); // some sort of '{status:'aborted'} object (the value underlying z.NEVER?)
	})
	.safeParse(42);

console.log(`succeeded:  ${parseResult.success}`); // false (correct behavior)

EDIT: looks like there are more scenarios that are causing this ‘zombie refine’ scenario. Here’s two more:

  • A failure within a refine causing it:
import * as z from 'zod';

const parseResult = z
	.string()
	.refine(x => false) // force anything/everything to fail
	.transform(x => {
		console.log("I don't get called"); // correct
		return x;
	})
	.refine((x: string) => {
		console.log("I shouldn't get called!!!!!!");
		console.log(typeof x); // string
		console.log(x); // '123'
	})
	.safeParse('123');

console.log(`succeeded:  ${parseResult.success}`); // false (correct behavior)
  • a failure within a regex causing it:
import * as z from 'zod';

const parseResult = z
	.string()
	.regex(/abc/) // fail because input doesn't match regex
	.transform(x => {
		console.log("I don't get called"); // correct
		return x;
	})
	.refine((x: string) => {
		console.log("I shouldn't get called!!!!!!");
		console.log(typeof x); // string
		console.log(x); // '123'
	})
	.safeParse('123');

console.log(`succeeded:  ${parseResult.success}`); // false (correct behavior)

These were pretty easy to stumble upon, so there are likely more out there as well.

About this issue

  • Original URL
  • State: open
  • Created a year ago
  • Reactions: 7
  • Comments: 17

Most upvoted comments

I can confirm:

UPDATE: Scratch that! I was missing fatal: true in the addIssue: https://zod.dev/?id=abort-early

Works around the issue, but this is still a bug since zod violates it’s own typings.

For example, without the fatal flag to addIssue:

export const Numeric = z.union([bigDecimalSchema, z.number(), z.string().min(1), z.bigint()]).transform((v, ctx) => {
  try {
    return Big(v);
  } catch (err) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: 'invalid_numeric: ' + (isErrorWithMessage(err) ? err.message : ''),
    });
    return z.NEVER;
  }
});

// This typechecks fine, since `v` should be `BigDecimal`
// but without the `fatal` flag it can be `{ "status": "aborted" }`
Numeric.refine(v => v.gte(0))

Either the type signature of refine should be changed to reflect the fact that it may receive aborted parses (e.g. { "status": "aborted" }) (in which case the current shape of the abort message is not safe since that might be a valid instance of the type I am parsing) or it should never call future refinements after receiving a NEVER.

I cannot think of a reason I would return z.NEVER without wanting an early exit. I’m assuming there is some code-level architecture reason for the redundancy between fatal and the NEVER return.

It seems like refinement functions (such as .regex() or .min() or things like that) throws non-fatal issue so that parsing process will continue.

To stop parsing you must mark them as fatal manually. (as documented here: https://zod.dev/?id=abort-early) This rule also applies to .transform() too.

I couldn’t find any way to mark fatal the issues from functions like .min() 😕

import { z } from "zod";

const numberInString = z
  .string()
  .transform((val, ctx) => {
    const parsed = parseInt(val);
    if (isNaN(parsed)) {
      ctx.addIssue({
        fatal: true, // this line is very important...
        code: z.ZodIssueCode.custom,
        message: "Not a number",
      });
      return z.NEVER;
    }
    return parsed;
  })
  .refine((x) => {
    // ...to ensure this `x` is not NaN
    console.log(x);
    return true;
  });

console.log(numberInString.safeParse("aaa"));
console.log("---");
console.log(numberInString.safeParse("1234"));

the above code outputs

{ success: false, error: [Getter] }
---
1234
{ success: true, data: 1234 }

if fatal: false(default), it shows (z.NEVER is actually { status: "aborted" } on runtime.)

{ status: 'aborted' }
{ success: false, error: [Getter] }
---
1234
{ success: true, data: 1234 }

I am using 3.21.2 and I confirm that refine is called even when the previous regex fails.

import { z } from "zod";

const schema = z
  .object({
    birthDate: z
      .string()
      .regex(/^\d{2}\.\d{2}\.\d{4}$/)
      .transform((input) => {
        const data = input.split(".");
        const day = data[0];
        const month = data[1];
        const year = data[2];
        return `${year}-${month}-${day}`;
      })
  })
  .refine((data) => {
    console.log(data.birthDate); // 👈 This should never log if regex fails
    return true;
  });

schema.parse({
  birthDate: "10.11.203" // ❌ This will incorrectly log "10.11.203" and the regex Error will throw after that
});

@Darkhogg In case it’s helpful as a temporary workaround, using the transform to do the validation still seems to work fine

https://zod.dev/?id=validating-during-transform

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.