It's a little bit hard to say exactly what the best approach is because I think there are a few options.
First, I would suggest keeping refinements as localized to the field they apply to as possible. For example, instead of calling refine
on the entire object, I would call refine
on the z.string()
for the name
field:
export const vehicleZodObject = z.object({
name: z.string().refine(isUnique, { message: 'Characters must be unique' },
engine: type: z.enum(['electric', 'combustion']),
});
Next, it's possible to define a helper function which applies refinements to the schema it's handed. You would set it up with an interface that the two schemas share and then call the refine
function on the schema handed in:
import { z } from "zod";
interface IShared {
a: string;
b: string;
}
function attachRefinements<O extends IShared, T extends z.ZodTypeDef, I>(
schema: z.ZodType<O, T, I>
) {
return schema.refine((shared) => shared.a !== shared.b, {
message: "a and b must be unique"
});
}
const schema1 = attachRefinements(
z.object({
custom: z.boolean(),
a: z.string(),
b: z.string()
})
);
const schema2 = attachRefinements(
z.object({
a: z.string(),
b: z.string(),
special: z.number()
})
);
Even in this case, I would suggest creating a base
schema that just has fields a
and b
, adding the refinement just to the base
schema and then building schema1
and schema2
using z.intersection
. This goes along with the principle that the refinement is kept as close to the place it applies to as possible.
const base = z.object({
a: z.string(),
b: z.string(),
}).refine((x) => x.a !== x.b, {
message: 'a and b must be unique',
});
const schema1 = z.intersection(base, z.object({
custom: z.boolean(),
});
const schema2 = z.intersection(base, z.object({
special: z.number()
});