Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
kevlugli opened this issue Aug 19, 2021 · 8 comments

Comments

@kevlugli
Copy link

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.

@scotttrinh
Copy link
Collaborator

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

@scotttrinh
Copy link
Collaborator

@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?

@kevlugli
Copy link
Author

kevlugli commented Aug 20, 2021

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.

@kevlugli
Copy link
Author

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)

@scotttrinh
Copy link
Collaborator

@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.

@kevlugli
Copy link
Author

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!

@scotttrinh
Copy link
Collaborator

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);

@colinhacks
Copy link
Owner

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants