[FEATURE] [v7] Support custom validation errors shape
herkulano opened this issue · comments
Is there an existing issue for this?
- I have searched the existing issues and found nothing that matches
Library version (optional)
No response
Ask a question
In React Aria Components the Form takes a flattened error list: https://react-spectrum.adobe.com/react-aria/forms.html#schema-validation
Any ideas on how to change the handling of validation errors from the library, instead of having to manipulate the result from the response?
Additional context
No response
Conform uses yet another format for the errors: https://conform.guide/api/zod/parseWithZod
It would be nice to change the way schema is parsed so that it would be flexible for any case.
next-safe-action v7 (currently next branch/channel) follows Zod's format() output to build validation errors, since it correctly handles validation for nested objects in schemas, see #51.
I agree that in some cases it's better to just deal with a flattened error object, for instance when you don't need to use nested schemas. My idea is to export a flattenValidationErrors
function that will flatten the formatted validation errors, so something like this:
Supposing we have defined this schema using Zod:
import { z } from "zod";
const schema = z.object({
username: z.string().min(3).max(30),
email: z.string().email(),
age: z.number().positive(),
});
We can import flattenValidationErrors
from the library and pass result.ValidationErrors
to it.
import { flattenValidationErrors } from "next-safe-action";
const flattenedErrors = flattenValidationErrors(result.validationErrors);
flattenedErrors
, in this case, will have this structure:
type FlattenedErrors = {
formErrors: string[],
fieldErrors: {
username?: string[] | undefined,
email?: string[] | undefined,
age?: string[] | undefined,
}
}
Please let me know your thoughts on this, thanks!
It would be nice to have that directly in the action, so that it could be flexible and the frontend wouldn't need to do anything.
const editProfile = authActionClient
.metadata({ actionName: "editProfile" })
.schema(z.object({ newUsername: z.string() }), (errors) => flattenValidationErrors(errors).fieldErrors)
.action(...);
I believe the server action should be responsible for giving back the correct values, and thinking of supporting react aria and useFormState
or useActionState
that expect the result to be already well formed.
Another issue with the schema is i18n. If you use something like zod-i18n-map
for localization of the error messages we need to do this before performing the zod parse.
import { makeZodI18nMap } from "zod-i18n-map"
z.setErrorMap(makeZodI18nMap({ t }))
My understanding is that this will be fixed with v7 because the middleware functions can be run before the schema function:
const deleteUser = authActionClient
.use(async ({ next, ctx }) => {
z.setErrorMap(makeZodI18nMap({ t }))
return next({ ctx });
})
.metadata({ actionName: "deleteUser" })
.schema(z.void())
.action(...);
If this is correct then the problem of the i18n of the schema is solved.
It would be nice to have that directly in the action
Yeah, I agree, this is a good idea. As a default though, I think it's good to keep Zod's emulated format
function, so nested errors are not discarded.
If you use something like
zod-i18n-map
... it will be fixed with v7 because the middleware functions can be run before theschema
function
I'm not familiar with zod-i18n-map
, but yes, middleware fns run before input validation. Just a note: schema
method doesn't actually validate input data, you just pass a validation schema to it, and then the parsing is performed inside the action
method.
@TheEdoRan I thought that by using .schema(z.object({ newUsername: z.string() }))
the schema would be validated server side and sending back the validationErrors
from the server to the client.
If the only purpose of the schema is typing, I believe it would be clearer to have the schema as a type instead of a value:
const editProfile = authActionClient<SchemaXYZ>
.metadata({ actionName: "editProfile" })
.action(...)
I still think it would be nice to have the validation done on the server automatically.
the schema would be validated server side and sending back the
validationErrors
from the server to the client. ... If the only purpose of the schema is typing, I believe it would be clearer to have the schema as a type instead of a value
It's not the schema that gets validated server side, it's the input data, thanks to the schema. What you pass to schema
is a validation schema function, that is then passed to the action
method (returned by schema
) as argument, to parse and validate the input data server side. So yes, it does something and it isn't just a type. What I'm saying is that the actual parsing is done inside action
and not in schema
, and middleware functions run before this step (unless you await the next
function, as explained in the logging middleware example).
You can check out the relevant code here, for the schema
method and here, for parsing and validation inside action
method.
@TheEdoRan got it! Thanks for explain it 🙏
If I understand correctly, in the recursive middleware function they only have access to the raw input, not to the validated input: https://github.com/TheEdoRan/next-safe-action/blob/next/packages/next-safe-action/src/index.ts#L110
I had a different model in my mind. I thought the sequence in the chained functions mattered, e.g.:
const editProfile = authActionClient
.metadata({ actionName: "editProfile" })
.use(...) // <-- runs before schema and only has access to the raw input
.schema(...) // <-- input is validated and returns the validated input to continue the chain or returns errors to the client
.use(...) // <-- runs after schema validation and has access to the validated input
.action(...)
From what I understand this is what really happens:
const editProfile = authActionClient
.metadata({ actionName: "editProfile" })
.use(...) // <-- is put on a queue [0] to run before the schema validation
.schema(...) // <-- sets the schema to be run after the middleware queue
.use(...) // <-- is put on a queue [1] to run before the schema validation
.action(...)
I feel this API can be misleading because of the chaining of functions, but maybe it's just me 😊
If I understand correctly, in the recursive middleware function they only have access to the raw input, not to the validated input
As arguments of middleware functions, yes, but they return a MiddlewareResult
object when you call the next
function, that extends the SafeActionResult
with additional data. So, if you await the next
function, you can access parsedInput
and do whatever you want with it, after the execution of middleware stack and server code function. Obviously parsedInput
is typed unknown
in this case, because the middleware works for every action defined using it.
From what I understand this is what really happens
You can't use use
after schema
, only before it. schema
returns bindArgsSchemas
and action
methods, as explained here.
I feel this API can be misleading because of the chaining of functions, but maybe it's just me
It works the same way as the tRPC middleware implementation, which I think is great (very composable, flexible and powerful).
So:
- if you simply return
next
function at the end of your middleware functions body, the next middleware in the chain will execute; - if you await the
next
function and then return its result in your middleware functions body, the entire stack, from that point in the chain, of middleware fns/action function gets executed. Result object will contain information about the executed action (very useful, for instance, to log action execution infos).
next-safe-action now supports this feature in v7.0.0-next.21. Documentation for it is currently available here.