colinhacks / zod

TypeScript-first schema validation with static type inference

Home Page:https://zod.dev

Repository from Github https://github.comcolinhacks/zodRepository from Github https://github.comcolinhacks/zod

Feature Request: `recursiveSafeParse` for Deep Schema Validation

LucasOta opened this issue · comments

Description

Currently, safeParse in Zod only validates the top-level structure of a schema, returning an object with success: boolean and error details if the input is invalid. However, when working with deeply nested schemas, validation errors within sub-properties are not captured in a structured way.

This proposal introduces recursiveSafeParse, a function that performs safe validation at all levels of a schema, ensuring that errors in sub-properties are also returned in a structured format.

Use Case

When validating deeply nested objects, safeParse only returns errors at the top level, making it difficult to handle validation failures in sub-properties effectively. A recursive version would allow structured error handling for all levels of a schema.

Proposed Solution

recursiveSafeParse should behave similarly to safeParse, but it should propagate validation errors from all nested objects. This approach ensures that all levels of the schema are validated safely without throwing exceptions.

Example on my code:

export class ZodErrorRecursive extends ZodError {
  recursiveError?: Record<string, ZodErrorRecursive>;

  constructor(issues: ZodIssue[], recursiveError?: Record<string, ZodErrorRecursive>) {
    super(issues);
    this.recursiveError = recursiveError;
  }
}

export function recursiveSafeParse(
  schema: ZodObject<any>,
  data: any,
): { success: false; error: ZodErrorRecursive } | { success: true; data: any } {
  const validationResult = schema.safeParse(data);

  if (validationResult.success) {
    return validationResult;
  }

  const recursiveErrors: Record<string, ZodErrorRecursive> = {};

  validationResult.error.errors.forEach((error) => {
    const { path } = error;
    const [field] = path;

    // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
    if (schema.shape[field!] instanceof ZodObject) {
      const nestedValidationResult = recursiveSafeParse(
        // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
        schema.shape[field!] as ZodObject<any>,
        // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
        data[field!] || {},
      );

      if (!nestedValidationResult.success) {
        recursiveErrors[field as string] = nestedValidationResult.error;
      }
    }
  });

  return {
    success: false,
    error: new ZodErrorRecursive(validationResult.error.errors, recursiveErrors),
  };
}

export interface FormattedZodErrorRecursive {
  path: string;
  errors?: FormattedZodErrorRecursive[];
}

export const formatRecursiveErrors = (error: ZodErrorRecursive) => {
  const formattedErrors: FormattedZodErrorRecursive[] = [];

  // Format main errors
  error.errors.forEach((issue) => {
    const path = issue.path.join('.');
    const formattedError: FormattedZodErrorRecursive = { path };

    // If there are recursive errors, format them as well
    if (
      error.recursiveError &&
      issue.path[0] !== undefined &&
      error.recursiveError[issue.path[0]]
    ) {
      // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
      formattedError.errors = formatRecursiveErrors(
        error.recursiveError[issue.path[0]!]!,
      );
    }

    formattedErrors.push(formattedError);
  });

  return formattedErrors;
};

Additional Considerations

  • Should recursiveSafeParse return a flat list of errors or maintain a structured format for better debugging?
  • Should it be an optional flag in safeParse, like safeParse({ recursive: true }), instead of a separate function?
  • Would this impact performance on deeply nested schemas with large datasets?

Next Steps

If this feature aligns with the goals of Zod, I am happy to refine the implementation, add test cases, and contribute with a PR. Let me know your thoughts!

In Zod 4 there are a number of recommended approaches to converting simple flat error object into various structured forms: https://zod.dev/error-formatting

I prefer a standard ZodError format that can be converted into various forms with utility functions over the addition of additional parse methods (there are already too many).