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

Addition of refine and/or superRefine methods #70

Open
nanaaikinson opened this issue Feb 5, 2025 · 1 comment
Open

Addition of refine and/or superRefine methods #70

nanaaikinson opened this issue Feb 5, 2025 · 1 comment

Comments

@nanaaikinson
Copy link

Zod has a refine method that lets you provide custom validation logic or apply additional validation rules to a schema. One use case of this is checking if two fields match. Below is a code snippet of how to check if passwords match in zod.

const schema = z
    .object({
        password: string(),
        passwordConfirmation: string(),
    })
    .refine((data) => data.password === data.passwordConfirmation, {
        message: "Passwords don't match",
        path: ["passwordConfirmation"],
    });

This example allows for asynchronous validation

// Asynchronous validation function
const checkUsernameAvailability = async (username: string): Promise<boolean> => {
  return fetch(`/api/check-username?username=${username}`)
    .then(res => res.json())
    .then(data => data.available);
};

// Zod schema
const userSchema = z.object({
  username: z.string().min(1, 'Username is required').refine(async (username) => {
    const isAvailable = await checkUsernameAvailability(username);
    return isAvailable;
  }, 'Username is not available'),
});

superRefine on the other hand is suitable for managing complex validation rules. It can perform multiple validation checks and provides detailed error handling. For example, you might use it to validate both an email address and a username during a user registration process.

// Asynchronous validation functions
const checkUsernameAvailability = async (username: string): Promise<boolean> => {
  return fetch(`/api/check-username?username=${username}`)
    .then(res => res.json())
    .then(data => data.available);
};

const validateEmail = async (email: string): Promise<boolean> => {
  return fetch(`/api/validate-email?email=${email}`)
    .then(res => res.json())
    .then(data => data.valid);
};

// Zod schema
const registrationSchema = z.object({
  email: z.string().email('Invalid email address'),
  username: z.string().min(1, 'Username is required'),
  password: z.string().min(6, 'Password must be at least 6 characters'),
}).superRefine(async (data, ctx) => {
  const { email, username } = data;

  // Email validation
  const isEmailValid = await validateEmail(email);
  if (!isEmailValid) {
    ctx.addIssue({
      path: ['email'],
      message: 'Email is already in use',
    });
  }

  // Username validation
  const isUsernameAvailable = await checkUsernameAvailability(username);
  if (!isUsernameAvailable) {
    ctx.addIssue({
      path: ['username'],
      message: 'Username is not available',
    });
  }
});
@nanaaikinson nanaaikinson changed the title Addition of refine and/or super refine methods Addition of refine and/or superRefine methods Feb 5, 2025
@Oudwins
Copy link
Owner

Oudwins commented Feb 5, 2025

Hey!
Thanks for creating the issue. I think you are right that we should support something like this.

We have the building blocks for something like this. In fact you can already create errors in a raw way by doing:

// inside a preTransform, test or anything really you get access to z.Ctx or z.ParseCtx (depricated)
// method interface = NewError(internals.PathBuilder path, e z.ZogError)
ctx.NewError(pathbuilder, e)

But pathbuilder type is inside the zog internals, and helper functions for creating zogErrors mostly depend on a test. So I don't recommend using any of it as it will most likely change.

Regarding the user facing API, I'm interested on how you would like it. Currently we have opted for "Tests" as the names rather than refine and superRefine.

This is the current API (which is not very well documented now that I look at it, we should have a page on the docs that goes over custom tests):

// This would be similar to `refine`. You are forced to set an error code for i18n.
z.String().Test(z.TestFunc(
"custom_error_code",
func(val any, ctx z.Ctx) bool {
 // custom test here
 return ok
}),
z.Message("my custom message")
)
// You can also create the test directly. Which has a few more customization options:
z.String().Test(z.Test{
// test config here
})

// For reference the test struct type is:
type Test struct {
	ErrCode      zconst.ZogErrCode
	Params       map[string]any
	ErrFmt       ErrFmtFunc
	ValidateFunc func(val any, ctx z.Ctx)
}

However, non of the current options handle adding errors in custom paths. You can do it by doing it manually but it would be a mess and you would get duplicate errors.

I think the key features that we are missing at the moment are:

  1. Easy way to specify an error's path
  2. ability to hand-roll your own errors such as super refine does. Where you don't actually return true or false but rather handle all the errors manually
  3. Potentially nicer API for creating the errors

I'm interested in your thoughts on what you would like the API to look like for all of this. Here are some half baked ideas on my end:

// Path option for errors?
schema := z.Struct(z.Schema{
   "hidden": z.String().Required(z.Path("shown")), 
   "shown": z.String(), 
})
errs := schema.Validate(&data)
/*
errs = 
{
 "shown": ["field is required"]
}
*/

// Optional Second argument on message for error path:
schema := z.Struct(z.Schema{
   "hidden": z.String().Required(z.Message("hidden is required", "shown")), 
   "shown": z.String(), 
})
errs := schema.Validate(&data)
/*
errs = 
{
 "shown": ["hidden is required"]
}
*/

// Super refine = z.Test + manual flag?
// you would still need to return a bool so APi is a little clunky
z.String().Test(z.Test{
ValidateFunc: func (val any, ctx z.Ctx) bool {
},
ManualErrors: true,
})

I'm not sure about the API for creating errors. Zog keeps a lot of info on the error struct. So it can be a little annoying to create them.

type ZogError interface {
	// returns the error code for the error. This is a unique identifier for the error. Generally also the ID for the Test that caused the error.
	Code() zconst.ZogErrCode
	// returns the data value that caused the error.
	// if using Schema.Parse(data, dest) then this will be the value of data.
	Value() any
	// Returns destination type. i.e The zconst.ZogType of the value that was validated.
	// if Using Schema.Parse(data, dest) then this will be the type of dest.
	Dtype() string
	// returns the params map for the error. Taken from the Test that caused the error. This may be nil if Test has no params.
	Params() map[string]any
	// returns the human readable, user-friendly message for the error. This is safe to expose to the user.
	Message() string
	// returns the wrapped error or nil if none
	Unwrap() error
}

These are the methods that Zog uses to create errors, let me know if you like these APIs or would prefer something different and why:

// Helper struct for dealing with zog errors. Beware this API may change
var Errors = errHelpers{}
// Create error from (originValue any, destinationValue any, test *p.Test)
func (e *errHelpers) FromTest(o any, destType zconst.ZogType, t *p.Test, p ParseCtx)
func (e *errHelpers) FromErr(o any, destType zconst.ZogType, err error)
func (e *errHelpers) WrapUnknown(o any, destType zconst.ZogType, err error) p.ZogError 
func (e *errHelpers) New(code zconst.ZogErrCode, o any, destType zconst.ZogType, params map[string]any, msg string, err error) p.ZogError 

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

2 participants