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
, likesafeParse({ 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).