TIL: Zod refinements are complicated
by Graham Marlow
Today I found myself at the bottom of a rabbit hole, exploring how Zod's refine method interacts with form validations. As with most things in programming, reality is never as clear-cut as the types make it out to be.
Today's issue concerns zod/issues/479, where refine validations aren't executed until all fields in the associated object are present. Here's a reframing of the problem:
The setup:
- I have a form with fields A and B. Both are required fields, say
required_a
andrequired_b
. - I have a validation that depends on the values of both A and B, say
complex_a_b
.
The problem:
If one of A or B is not filled out, the form parses with errors: [required_a]
,
not [required_a, complex_a_b]
. In other words, complex_a_b
only pops up as
an error when both A and B are filled out.
Here's an example schema that demonstrates the problem:
const schema = z
.object({
a: z.string(),
b: z.string(),
})
.refine((values) => !complexValidation(values.a, values.b), {
message: 'complex_a_b error',
})
This creates an experience where a user fills in A, submits, sees a validation
error pointing at B, fills in B, and sees another validation error pointing at
complex_a_b
. The user has to play whack-a-mole with the form inputs to make
sure all of the fields pass validation.
As a programmer, we're well-acquainted with error messages that work like this. And we hate them! Imagine a compiler that suppresses certain errors before prerequisite ones are fixed.
If you dig deep into the aforementioned issue thread, you'll come across the following solution (credit to jedwards1211):
const base = z.object({
a: z.string(),
b: z.string(),
})
const schema = z.preprocess((input, ctx) => {
const parsed = base.pick({ a: true, b: true }).safeParse(input)
if (parsed.success) {
const { a, b } = parsed.data
if (complexValidation(a, b)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['a'],
message: 'complex_a_b error',
})
}
}
return input
}, base)
Look at all of that extra logic! Tragic.
From a type perspective, I understand why Zod doesn't endeavor to fix this
particular issue. How can we assert the types of A or B when running the
complex_a_b
validation, if types A or B are implicitly optional? To evaluate
them optionally in complex_a_b
would defeat the type, z.string()
, that
asserts that the field is required.
How did I fix it for my app? I didn't. I instead turned to the form library, applying my special validation via the form API instead of the Zod API. I concede defeat.