colinhacks / zod

TypeScript-first schema validation with static type inference

Home Page:https://zod.dev

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

No .partial(), .deepPartial(), .merge() and others after using .refine() on ZodObject

kevlugli opened this issue · comments

The refine method on a ZodObject instance returns a ZodEffects instance which lacks any of the object methods. It broke some code after migrating to v3.
I don't know the reasons behind this change, I guess it's to support chaining multiple refine and transform calls, but maybe there's a way to keep the original methods.
It should also benefit another schema types' methods.

You can find some additional details and context in the RFC about v3

@kevlugli This also comes up a lot in the context of container types like ZodUnion, ZodIntersection, etc. If you want to give some concrete examples of how you're currently using post-transform/refine methods, maybe we can suggest some good alternatives that are valid in v3?

You can find some additional details and context in the RFC about v3

Yeah, I've read it before but I think it applies more to transformers than refinements.

@kevlugli This also comes up a lot in the context of container types like ZodUnion, ZodIntersection, etc. If you want to give some concrete examples of how you're currently using post-transform/refine methods, maybe we can suggest some good alternatives that are valid in v3?

Yep, that's actually true. In the case I was facing, I wanted to do this

// in some file
export const credentialDataSchema = object({
  firstName: string().nonempty(),
  lastName: string().nonempty(),
  birthday: date(),
  countryId: string(),
  zipCode: zipCodeSchema().nullish(),
}).refine(
  (data) => {
    const isUs = data.countryId === 'US';
    const isEmpty = data.zipCode === undefined || data.zipCode === null;

    return !((isUs && isEmpty) || (!isUs && !isEmpty));
  },
  {
    message: 'zipCode should be complete when countryId is US',
    path: ['zipCode'],
  },
);

// in another file
// in v1 done with .merge() and not .and()
export const userDataSchema = credentialDataSchema.and(
  object({
    email: string().email(),
    phone: string(),
  }),
);

// in yet another file
export const credentialEditionDataSchema = credentialDataSchema.partial()
export const userEditionDataSchema = userDataSchema.partial()

That's doable with Typescript's types
I ended implementing a custom function called "partialSchema" that goes through ZodEffects, ZodIntersection or ZodUnion and applies .partial() to the internal ZodObject schema. It's kinda nasty but it works.

Anyway I think it would be worth cosidering supporting that kind of behavior. Maybe with an external function like mine (but more polished), that allows to apply functions to the internal schemas. It should support ZodOptional and ZodNullable also.
If you think it's a good idea I could try to make it work. I need to find some free time first he.
(BTW amazing library, thanks)

@kevlugli

Your example demonstrates the difficulty of a general solution. refine assumes that the input type is not-partial, so you would get runtime errors when trying to access undefined fields in your refinement function. Take this example given your schemas:

credentialEditionDataSchema.parse({
  firstName: "Scott",
  lastName: "Trinh",
  zipCode: "100001",
  birthday: new Date(2030, 0, 1),
});

In your particular refinement's case, you're testing data.countryId === "US" so you don't fall into a runtime error, but imagine you wanted to normalize the string first like data.countryId.toUppercase() === "US", the compiler would not be able to tell you that it's invalid since the type of data is not partial.

I know it's annoying to have to respecify refinements, but I think if you end up transforming a type that you also need to refine, you'll want to maybe share refinements that are defensive and applicable to all of the possible input values.

const usZipcodeRefinement = (data: { countryId?: string; zipCode?: string | null }) => {
  const isUs = data.countryId === 'US';
  const isEmpty = data.zipCode === undefined || data.zipCode === null;

  return !((isUs && isEmpty) || (!isUs && !isEmpty));
};

Then you can export an unrefined version that you can call .partial().refine(usZipcodeRefinement) on and a refined version that you can use on it's own.

In your particular refinement's case, you're testing data.countryId === "US" so you don't fall into a runtime error, but imagine you wanted to normalize the string first like data.countryId.toUppercase() === "US", the compiler would not be able to tell you that it's invalid since the type of data is not partial.

Yeah that's a good point. I did know it was going to support an optional zipCode but in the schema is not evident.

I've already considered and tried the solution you suggested, but the derived userDataSchema (and others that are not included in the sample code) is used in a lot of places (within other intersections for ex.) and is very prone to error having to remember (or other devs having to guess) to add the refinement every time.
I know there is a way to do it changing the data model, and I understand why it would be controversial to add support for what I'm asking, but I wanted to know what do you think about it. Thanks!

I've already considered and tried the solution you suggested, but the derived userDataSchema (and others that are not included in the sample code) is used in a lot of places (within other intersections for ex.) and is very prone to error having to remember (or other devs having to guess) to add the refinement every time.

Yeah, I think the only way to avoid that is to use naming conventions and comments like:

// Note: this schema is unchecked for business logic refinement
export const unsafeCredentialDataSchema = z.object({
  // ...
});

export const credentialDataSchema = unsafeCredentialDataSchema.refine(usZipcodeRefinement);

+1 to Scott's explanation and workaround. This is by design.