TheEdoRan / next-safe-action

Type safe and validated Server Actions in your Next.js (App Router) project.

Home Page:https://next-safe-action.dev

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

[FEATURE] [v7] Support schema nested objects validation

alexambrinos opened this issue · comments

commented

Are you using the latest version of this library?

  • I verified that the issue exists in the latest next-safe-action release

Is there an existing issue for this?

  • I have searched the existing issues and found nothing that matches

Suggest an idea

I wanted to implement a form where a user enters the customer info and the address info at the same time. I've made a custom zod validation like this:
z.object({address: addressSchema, customer: customerSchema })
I did it this way since I can automatically get the schema from drizzle-orm based on the db.
But the problem i faced when implementing this is that the zod validation breaks since flatten() can't have nested objects. Wouldn't it be better to implement the zod validation errors using the format() function instead so we can have nested objects?
The way I need to implement it currently is using two different actions so each form has it's own validation but I lose the ability to add the customer and address in a db transaction since they will be sepparate.

Additional context

No response

Hi @alexambrinos, thank you for opening this issue. I understand the problem and yes, it would be nice to be able to pass multiple schemas into a single input object.

next-safe-action, since version 6, uses TypeSchema under the hood to support a wide range of validation libraries, so it's no longer tied to just Zod, therefore it can't and doesn't use format() or flatten() from Zod to build the validationErrors object.

When I released version 6, I wrote a custom function to build the validationErrors object and keep compatibility with previous versions of the library. This usually works well, but in more complex cases like yours, it breaks and the resulting object is quite ugly, with duplicate error messages for parent and children schema properties.

The only (robust) solution that comes to mind is to return the actual issues array when validation fails. So, for instance:

With a schema like this one:

const schema = z.object({
  productSchema: z.object({
    name: z.string().min(1).max(30),
  }),
  userSchema: z.object({
    username: z.string().min(1).max(20),
  }),
});

Assuming that validation fails for both nested properties, you get back a broken validationErrors object like this:

{
  productSchema: [
    "String must contain at least 1 character(s)"
  ],
  name: [
    "String must contain at least 1 character(s)"
  ],
  userSchema: [
    "String must contain at least 1 character(s)"
  ],
  username: [
    "String must contain at least 1 character(s)"
  ]
}

Instead, if we rely on the "raw" issues array of objects from TypeSchema, we get back something like this:

[
  {
    message: "String must contain at least 1 character(s)",
    path: ["productSchema", "name"]
  },
  {
    message: "String must contain at least 1 character(s)",
    path: ["userSchema", "username"]
  }
]

I honestly think that the second one is much more reliable, since it allows granular control over validation errors, with the only drawback of being a little more verbose. It's also a breaking change though, so it requires some thinking to understand if it's the best approach for this problem, but I'm pretty confident it is.

Please let me know what you think about it. If we all agree that this is the best way to handle validation errors from now on, I'll include these changes in next-safe-action v7. Thank you!

A different approach would be to emulate (recreate) the format() function from Zod: see this. This API works well with forms and with nested objects in schemas. It still is a breaking change though, so it would be included in v7.

I'd vote for emulating format() and return the errors as a nested object

The validationErrors object in next-safe-action@next now follows the same structure as Zod's format() function. Please let me know what you think about this solution. It was quite difficult to implement, but hopefully the lib now supports any level of nested objects in schemas, while also being fully type safe.

commented

It returns the errors nested perfectly! The only problem I saw when trying to implement it is if I console.log(result.validationErrors) it shows the object with the properties, but when i try to access a specific field like console.log(result.validationErrors?.customer?.firstName?._errors) it shows as undefined even though it has an error.

when i try to access a specific field ... it shows as undefined even though it has an error

I cannot reproduce the issue. Can you please provide a link to an example repo/StackBlitz project so I can investigate the problem? Thank you!

I managed to reproduce it. If your nested object is optional...

with this schema:

const schema = z.object({
  name: z.string(),
  customer: z.object({
    firstName: z.string(),
  }),
});

It works fine and validationErrors?.customer?.firstName?._errors is typed as string[] | undefined.

But, if I adjust the schema and make customer optional:

const schema = z.object({
  name: z.string(),
  customer: z
    .object({
      firstName: z.string(),
    })
    .optional(),
});

It shows that firstName doesn't exist in customer.

The infered type of validationErrors looks like this:

{
      _errors?: string[] | undefined;
      name?: { _errors?: string[] | undefined } | undefined;
      customer?: { _errors?: string[] | undefined } | undefined;
    }
  | undefined
commented

Sorry, haven't had the time to get into why that was happening, but I can confirm that my address schema was optional, since the user decides if they want to insert the address now or later.

It's caused by the SchemaErrors type, that doesn't expect an optional value

export type SchemaErrors<S> = {
	[K in keyof S]?: S[K] extends object ? Extend<ErrorList & SchemaErrors<S[K]>> : ErrorList;
} & {};

this adjustment seems to fix it:

type SchemaErrorValue<Value> = Value extends object ? Extend<ErrorList & SchemaErrors<Value>> : ErrorList;

type IsNullable<T> = undefined extends T ? true : null extends T ? true : false;

export type SchemaErrors<S> = {
	[K in keyof S]?: IsNullable<S[K]> extends true ? SchemaErrorValue<NonNullable<S[K]>> : SchemaErrorValue<S[K]>;
} & {};

Thank you @theboxer for your findings.

I just tried to declare the SchemaErrors type like this and it appears to be working, without declaring additional util types. I updated the S[K] extends object part to S[K] extends object | null | undefined. Please let me know if it works for you as well. Thanks!

export type SchemaErrors<S> = {
  [K in keyof S]?: S[K] extends object | null | undefined
    ? Extend<ErrorList & SchemaErrors<S[K]>>
    : ErrorList;
} & {};

Should be fixed in v7.0.0-next.6.