How to implement zod as schema validation?

main
aab433dChore: Completed jest/typescript conversion of core tests (#4922)
This post might have stale content, as main is 2 commits ahead.

How to implement zod as schema validation?

Avatar of Claude Haiku 4.5Claude Haiku 4.5commented

I'll help you explore how to implement Zod as schema validation in react-jsonschema-form. Let me search the codebase for any existing Zod integration or schema validation patterns.

Great! I can see that Zod is already a dependency in the project. Let me explore more about how validation works in RJSF and check if there's any existing Zod integration.

Let me check the validator-ajv8 package structure to understand how validation is implemented:

Now let me look at the validator interface to understand what needs to be implemented:

Let me continue reading the ValidatorType interface:

Now let me look at the actual validator implementation in AJV8:

Let me check if there are any examples or documentation on custom validators:

Perfect! Now I have a good understanding of how RJSF works. Let me create a comprehensive response about implementing Zod as schema validation:

Perfect! Now I have all the information I need. Let me provide a comprehensive answer.

##How to Implement Zod as Schema Validation in React-JSONSchema-Form

Based on the codebase, here are the main approaches to use Zod with react-jsonschema-form (RJSF):

###Approach 1: Convert Zod Schema to JSON Schema (Recommended)

The most straightforward way is to convert your Zod schema to JSON Schema since RJSF is built around JSON Schema validation. Zod has built-in support for this:

import { z } from 'zod';
import Form from '@rjsf/core';
import validator from '@rjsf/validator-ajv8';

// Define your Zod schema
const userSchema = z.object({
  name: z.string().min(1, "Name is required"),
  email: z.string().email("Invalid email"),
  age: z.number().int().positive().optional(),
});

// Convert to JSON Schema (Zod v4.2+)
const jsonSchema = userSchema.toJSONSchema();

// Use with RJSF
export function MyForm() {
  return (
    <Form 
      schema={jsonSchema} 
      validator={validator}
      onSubmit={(data) => console.log(data.formData)}
    />
  );
}

Using the dedicated library for more control:

If you need more advanced options, use the zod-to-json-schema library:

import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import Form from '@rjsf/core';
import validator from '@rjsf/validator-ajv8';

const userSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  preferences: z.object({
    newsletter: z.boolean().default(true),
    notifications: z.boolean().default(false),
  }),
}).strict();

// Convert with options
const jsonSchema = zodToJsonSchema(userSchema, {
  target: 'jsonSchema2020-12',
  ref: 'definitions',
  definitions: {},
});

export function MyForm() {
  return <Form schema={jsonSchema} validator={validator} />;
}

###Approach 2: Combine JSON Schema Validation with Zod Custom Validation

Use JSON Schema for basic validation and add Zod validation through RJSF's customValidate prop:

import { z } from 'zod';
import Form, { FormProps } from '@rjsf/core';
import { CustomValidator, RJSFSchema } from '@rjsf/utils';
import validator from '@rjsf/validator-ajv8';

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

// Convert just the base schema
const jsonSchema: RJSFSchema = {
  type: 'object',
  properties: {
    password: { type: 'string', minLength: 8 },
    confirmPassword: { type: 'string' },
  },
  required: ['password', 'confirmPassword'],
};

// Custom validation using Zod
const customValidate: CustomValidator = (formData, errors) => {
  try {
    userSchema.parse(formData);
  } catch (error) {
    if (error instanceof z.ZodError) {
      error.errors.forEach((err) => {
        const path = err.path.join('.');
        if (path && errors[path]) {
          errors[path].addError(err.message);
        }
      });
    }
  }
  return errors;
};

export function MyForm() {
  return (
    <Form 
      schema={jsonSchema}
      validator={validator}
      customValidate={customValidate}
      onSubmit={(data) => console.log(data.formData)}
    />
  );
}

###Approach 3: Create a Custom Zod Validator (Advanced)

You can implement the ValidatorType interface [blocked] to create a fully custom Zod-based validator:

import { z } from 'zod';
import { ValidatorType, ValidationData, RJSFSchema, CustomValidator, ErrorTransformer, UiSchema, ErrorSchema } from '@rjsf/utils';

class ZodValidator<T = any> implements ValidatorType<T, RJSFSchema> {
  private schema: z.ZodSchema;

  constructor(zodSchema: z.ZodSchema) {
    this.schema = zodSchema;
  }

  validateFormData(
    formData: T | undefined,
    schema: RJSFSchema,
    customValidate?: CustomValidator<T, RJSFSchema>,
    transformErrors?: ErrorTransformer<T, RJSFSchema>,
    uiSchema?: UiSchema<T, RJSFSchema>,
  ): ValidationData<T> {
    const errors: any[] = [];
    const errorSchema: ErrorSchema<T> = {};

    // Validate with Zod
    const result = this.schema.safeParse(formData);
    
    if (!result.success) {
      result.error.errors.forEach((err) => {
        const path = err.path.join('.');
        errors.push({
          message: err.message,
          property: `.${path}`,
          name: err.code,
        });
      });
    }

    // Apply custom validation
    if (customValidate) {
      const fieldErrors: ErrorSchema<T> = {};
      customValidate(formData, fieldErrors, uiSchema, errorSchema);
      Object.assign(errorSchema, fieldErrors);
    }

    // Transform errors if needed
    if (transformErrors) {
      transformErrors(errors, uiSchema);
    }

    return { errors, errorSchema };
  }

  isValid(schema: RJSFSchema, formData: T | undefined, rootSchema: RJSFSchema): boolean {
    const result = this.schema.safeParse(formData);
    return result.success;
  }

  rawValidation<Result = any>(schema: RJSFSchema, formData?: T): { errors?: Result[]; validationError?: Error } {
    const result = this.schema.safeParse(formData);
    if (!result.success) {
      return { errors: result.error.errors as any[] };
    }
    return { errors: undefined };
  }
}

// Usage
const myZodSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
});

const zodValidator = new ZodValidator(myZodSchema);

export function MyForm() {
  return (
    <Form 
      schema={{}} // Schema is handled by Zod validator
      validator={zodValidator}
    />
  );
}

###Key Considerations

  1. JSON Schema Focus: RJSF is fundamentally built around JSON Schema, so converting Zod schemas to JSON Schema is the most natural approach.

  2. Zod's Built-in Support: Modern versions of Zod (v4.2+) have native .toJSONSchema() support, eliminating the need for third-party conversion libraries.

  3. Advanced Validation: For Zod features that don't have JSON Schema equivalents (like .refine(), .superRefine()), use the customValidate prop.

  4. TypeScript Integration: Both approaches maintain full TypeScript type safety with RJSF's generic type parameters.

  5. Dependencies: The project already has Zod as a dependency (^3.25.76 || ^4.1.8), so no additional installation is needed.

The recommended approach is Approach 1 (converting Zod to JSON Schema) as it leverages RJSF's native capabilities while preserving Zod's excellent DX. Use Approach 2 when you need additional validation logic beyond what JSON Schema can express.

Avatar of Nguyen Tuan NgocNguyen Tuan Ngoccommented

How can I have refine and superRefine with unknown schema input? As mentioned, customValidate extends validation with JavaScript code based on the current form data. However, my use case is for unknown schema from the server through GET request. The customValidate might not know which field to validate. The refine, super refine can be use use when i need to validate Date of birth field (require old date), or from-to date picker when require from date must lower then to date, or place picker where you need to filter out base on a field (province, city, country,...)

Avatar of Claude Haiku 4.5Claude Haiku 4.5commented

Great question! This is a challenging use case. Let me explore the codebase to understand how to handle dynamic schema validation with cross-field dependencies.

Let me check the actual CustomValidator type definition:

Now let me check what FormValidation is:

Perfect! Now I understand the structure. Let me create a comprehensive solution for your use case:

Excellent! Now I have all the information I need. Let me provide a comprehensive solution for your use case.

##How to Use Zod's refine and superRefine with Unknown Server Schemas

Your use case is very valid. When receiving dynamic schemas from the server, you can't hardcode a customValidate function because you don't know the fields in advance. Here are several solutions:

###Solution 1: Store Validation Rules in Schema Metadata (Recommended)

Add custom validation rules to your server schema using the JSON Schema x- extension fields. These will be picked up by your customValidate function dynamically:

// Server schema with custom validation metadata
const schemaFromServer = {
  type: 'object',
  properties: {
    dateOfBirth: {
      type: 'string',
      format: 'date',
      'x-validate': 'dateInPast', // Custom validator name
    },
    fromDate: {
      type: 'string',
      format: 'date',
      'x-validate': 'dateRange',
      'x-dateRangeCompare': 'toDate', // Compare with this field
    },
    toDate: {
      type: 'string',
      format: 'date',
      'x-validate': 'dateRange',
      'x-dateRangeCompare': 'fromDate',
      'x-compareLessThan': false, // Must be greater than compared field
    },
    province: {
      type: 'string',
      'x-validate': 'location',
    },
    city: {
      type: 'string',
      'x-validate': 'location',
      'x-dependsOn': 'province', // Depends on another field
    },
  },
};

// Validation rules registry - can be imported from server or static
const validationRulesRegistry = {
  dateInPast: (value: string) => {
    if (!value) return { valid: true };
    const date = new Date(value);
    return {
      valid: date < new Date(),
      error: 'Date must be in the past',
    };
  },

  dateRange: (value: string, formData: any, fieldName: string, schema: any) => {
    if (!value) return { valid: true };
    const compareFieldName = schema['x-dateRangeCompare'];
    const compareValue = formData[compareFieldName];
    
    if (!compareValue) return { valid: true };
    
    const currentDate = new Date(value);
    const compareDate = new Date(compareValue);
    const isLessThan = schema['x-compareLessThan'] !== false;
    
    if (isLessThan && currentDate >= compareDate) {
      return {
        valid: false,
        error: `Must be before ${compareFieldName}`,
      };
    }
    if (!isLessThan && currentDate <= compareDate) {
      return {
        valid: false,
        error: `Must be after ${compareFieldName}`,
      };
    }
    
    return { valid: true };
  },

  location: (value: string, formData: any, fieldName: string, schema: any) => {
    if (!value) return { valid: true };
    
    const dependsOn = schema['x-dependsOn'];
    if (dependsOn && !formData[dependsOn]) {
      return {
        valid: false,
        error: `Please select ${dependsOn} first`,
      };
    }
    
    return { valid: true };
  },
};

// Generic customValidate function that inspects schema metadata
const createDynamicCustomValidator = (schema: RJSFSchema) => {
  return (
    formData: any,
    errors: FormValidation<any>,
  ): FormValidation<any> => {
    const properties = (schema.properties as Record<string, RJSFSchema>) || {};

    Object.entries(properties).forEach(([fieldName, fieldSchema]) => {
      const validatorName = fieldSchema['x-validate'] as string;
      
      if (validatorName && validationRulesRegistry[validatorName as keyof typeof validationRulesRegistry]) {
        const validatorFn = validationRulesRegistry[validatorName as keyof typeof validationRulesRegistry];
        const result = validatorFn(
          formData[fieldName],
          formData,
          fieldName,
          fieldSchema,
        );

        if (!result.valid && errors[fieldName]) {
          errors[fieldName]!.addError(result.error);
        }
      }
    });

    return errors;
  };
};

export function DynamicForm() {
  const [schema, setSchema] = useState<RJSFSchema | null>(null);

  useEffect(() => {
    // Fetch schema from server
    fetch('/api/form-schema')
      .then(res => res.json())
      .then(schema => setSchema(schema));
  }, []);

  if (!schema) return <div>Loading...</div>;

  return (
    <Form
      schema={schema}
      validator={validator}
      customValidate={createDynamicCustomValidator(schema)}
      onSubmit={(data) => console.log(data.formData)}
    />
  );
}

###Solution 2: Hybrid Approach - Convert Zod Rules and Store in Schema

If you're managing validation with Zod on the backend, serialize the rules to the schema:

// Backend (Node.js with Zod)
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';

const userSchema = z.object({
  dateOfBirth: z
    .string()
    .pipe(z.coerce.date())
    .refine((date) => date < new Date(), {
      message: 'Date must be in the past',
    }),
  fromDate: z.string().pipe(z.coerce.date()),
  toDate: z.string().pipe(z.coerce.date()),
}).refine((data) => data.fromDate < data.toDate, {
  message: 'From date must be before to date',
  path: ['toDate'],
});

// Convert to JSON Schema and add metadata
const jsonSchema = zodToJsonSchema(userSchema);
const enhancedSchema = {
  ...jsonSchema,
  properties: {
    ...jsonSchema.properties,
    dateOfBirth: {
      ...jsonSchema.properties?.dateOfBirth,
      'x-validate': 'dateInPast',
    },
    toDate: {
      ...jsonSchema.properties?.toDate,
      'x-validate': 'dateRange',
      'x-dateRangeCompare': 'fromDate',
    },
  },
};

// Send to frontend
res.json(enhancedSchema);

// Frontend
export function DynamicForm() {
  const [schema, setSchema] = useState<RJSFSchema | null>(null);

  useEffect(() => {
    fetch('/api/form-schema')
      .then(res => res.json())
      .then(schema => setSchema(schema));
  }, []);

  return (
    <Form
      schema={schema!}
      validator={validator}
      customValidate={createDynamicCustomValidator(schema!)}
    />
  );
}

###Solution 3: Event-Driven Validation with Field Dependencies

For complex scenarios with multiple field dependencies (like province -> city -> location), use a validation engine:

type ValidatorFn = (
  value: any,
  formData: any,
  fieldName: string,
  fieldSchema: RJSFSchema,
) => { valid: boolean; error?: string };

interface ValidationRule {
  type: 'dateInPast' | 'dateRange' | 'conditional' | 'custom';
  fields: string[]; // Which fields to validate
  condition?: (formData: any) => boolean;
  validate: ValidatorFn;
}

class DynamicValidator {
  private rules: ValidationRule[] = [];

  registerRule(rule: ValidationRule) {
    this.rules.push(rule);
  }

  createCustomValidator(schema: RJSFSchema) {
    return (formData: any, errors: FormValidation<any>) => {
      for (const rule of this.rules) {
        // Skip if condition doesn't match
        if (rule.condition && !rule.condition(formData)) {
          continue;
        }

        // Validate all fields in the rule
        for (const fieldName of rule.fields) {
          const fieldSchema = (schema.properties as Record<string, RJSFSchema>)?.[
            fieldName
          ];
          if (!fieldSchema || !errors[fieldName]) {
            continue;
          }

          const result = rule.validate(
            formData[fieldName],
            formData,
            fieldName,
            fieldSchema,
          );

          if (!result.valid) {
            errors[fieldName]!.addError(result.error!);
          }
        }
      }

      return errors;
    };
  }
}

// Usage
const dynamicValidator = new DynamicValidator();

// Register rules for date range validation
dynamicValidator.registerRule({
  type: 'dateRange',
  fields: ['fromDate', 'toDate'],
  validate: (value, formData, fieldName, schema) => {
    if (fieldName === 'toDate' && formData.fromDate && formData.toDate) {
      if (new Date(formData.fromDate) >= new Date(formData.toDate)) {
        return {
          valid: false,
          error: 'To date must be after from date',
        };
      }
    }
    return { valid: true };
  },
});

// Register rules for location dependency
dynamicValidator.registerRule({
  type: 'conditional',
  fields: ['city'],
  condition: (formData) => !!formData.province,
  validate: async (value, formData, fieldName) => {
    if (!value) return { valid: true };
    
    // Could call API to validate city belongs to province
    const isValid = await validateCityForProvince(formData.province, value);
    return {
      valid: isValid,
      error: isValid ? undefined : 'City not found in selected province',
    };
  },
});

export function DynamicForm({ schemaFromServer }: { schemaFromServer: RJSFSchema }) {
  return (
    <Form
      schema={schemaFromServer}
      validator={validator}
      customValidate={dynamicValidator.createCustomValidator(schemaFromServer)}
    />
  );
}

###Solution 4: Custom Validator Implementation (Complete Control)

If you need full control and want to handle Zod validation directly, implement a custom validator:

import { z } from 'zod';
import { ValidatorType, ValidationData, RJSFSchema, FormValidation, ErrorSchema } from '@rjsf/utils';

class ZodDynamicValidator implements ValidatorType<any, RJSFSchema> {
  private zodSchemaMap = new Map<string, z.ZodSchema>();

  /**
   * Register a Zod schema for a specific form
   */
  registerSchema(formId: string, zodSchema: z.ZodSchema) {
    this.zodSchemaMap.set(formId, zodSchema);
  }

  validateFormData(
    formData: any,
    schema: RJSFSchema,
  ): ValidationData<any> {
    const zodSchema = this.zodSchemaMap.get(schema.$id || 'default');
    const errors: any[] = [];
    const errorSchema: ErrorSchema = {};

    if (!zodSchema) {
      console.warn('No Zod schema registered for this form');
      return { errors, errorSchema };
    }

    // Parse with Zod
    const result = zodSchema.safeParse(formData);

    if (!result.success) {
      result.error.errors.forEach((err) => {
        const fieldPath = err.path.join('.');
        const property = `.${fieldPath}`;

        errors.push({
          message: err.message,
          name: err.code,
          property,
          stack: `${property} ${err.message}`,
        });

        // Build nested error schema
        let currentLevel = errorSchema;
        for (const part of err.path.slice(0, -1)) {
          if (!currentLevel[part]) {
            currentLevel[part] = {};
          }
          currentLevel = currentLevel[part];
        }

        const lastPart = err.path[err.path.length - 1];
        if (lastPart !== undefined) {
          if (!currentLevel[lastPart]) {
            currentLevel[lastPart] = { __errors: [] };
          }
          if (!currentLevel[lastPart].__errors) {
            currentLevel[lastPart].__errors = [];
          }
          currentLevel[lastPart].__errors!.push(err.message);
        }
      });
    }

    return { errors, errorSchema };
  }

  isValid(schema: RJSFSchema, formData: any): boolean {
    const zodSchema = this.zodSchemaMap.get(schema.$id || 'default');
    if (!zodSchema) return false;
    return zodSchema.safeParse(formData).success;
  }

  rawValidation<Result = any>(
    schema: RJSFSchema,
    formData?: any,
  ): { errors?: Result[]; validationError?: Error } {
    const zodSchema = this.zodSchemaMap.get(schema.$id || 'default');
    if (!zodSchema) {
      return { errors: undefined };
    }

    const result = zodSchema.safeParse(formData);
    if (!result.success) {
      return { errors: result.error.errors as any[] };
    }
    return { errors: undefined };
  }

  reset() {
    this.zodSchemaMap.clear();
  }
}

// Usage
const zodValidator = new ZodDynamicValidator();

// When fetching schema from server, also fetch or construct Zod schema
async function loadFormSchema(formId: string) {
  const response = await fetch(`/api/form-schema/${formId}`);
  const { jsonSchema, zodSchemaCode } = await response.json();

  // Reconstruct Zod schema from code or use a pre-registered schema
  // This is a simplified example - you might want to use a safer approach
  const zodSchema = eval(zodSchemaCode); // Use with caution!
  
  zodValidator.registerSchema(formId, zodSchema);

  return jsonSchema;
}

export function DynamicForm({ formId }: { formId: string }) {
  const [schema, setSchema] = useState<RJSFSchema | null>(null);

  useEffect(() => {
    loadFormSchema(formId).then(setSchema);
  }, [formId]);

  if (!schema) return <div>Loading...</div>;

  return (
    <Form
      schema={{ ...schema, $id: formId }}
      validator={zodValidator}
      onSubmit={(data) => console.log(data.formData)}
    />
  );
}

###Comparison of Solutions

SolutionProsConsBest For
#1: Schema MetadataSimple, no code eval, easy to debugLimited validation logicSimple validations (dates, dependencies)
#2: Hybrid Zod + SchemaUses Zod on backend, no unsafe codeRequires dual validation setupBackend with Zod + frontend RJSF
#3: Validation EngineHighly composable, event-drivenMore boilerplateComplex scenarios with many rules
#4: Custom ValidatorComplete control, full Zod featuresSecurity risks with eval(), complexAdvanced Zod features like .refine()

###Recommended Implementation Pattern

For your use case with unknown server schemas, I recommend Solution #1 + #2 combined:

/**
 * Backend: Generate schema with validation metadata
 */
export async function generateFormSchema(entityType: string) {
  const zodSchema = schemaRegistry[entityType]; // Get from registry
  const jsonSchema = zodToJsonSchema(zodSchema);

  return {
    jsonSchema: enrichSchemaWithMetadata(jsonSchema, schemaRegistry[entityType]),
    validationRules: extractValidationRules(zodSchema), // Export rules metadata
  };
}

/**
 * Frontend: Use metadata-driven validation
 */
export function DynamicForm({ entityType }: { entityType: string }) {
  const [schema, setSchema] = useState<RJSFSchema | null>(null);
  const [validationRules, setValidationRules] = useState<ValidationRule[]>([]);

  useEffect(() => {
    fetch(`/api/form-schema/${entityType}`)
      .then(r => r.json())
      .then(({ jsonSchema, validationRules }) => {
        setSchema(jsonSchema);
        setValidationRules(validationRules);
      });
  }, [entityType]);

  if (!schema) return <div>Loading...</div>;

  const validator = createDynamicValidator(validationRules);

  return (
    <Form
      schema={schema}
      validator={ajv8Validator}
      customValidate={validator}
      onSubmit={(data) => validateAndSubmit(data.formData)}
    />
  );
}

This approach:

  • ✅ Uses Zod for backend validation
  • ✅ Converts to JSON Schema for RJSF
  • ✅ Adds metadata for complex validations
  • ✅ No unsafe code evaluation
  • ✅ Dynamic and extensible
  • ✅ Handles cross-field dependencies

Would you like me to provide a complete working example with all three pieces (backend schema generation, metadata extraction, and frontend implementation)?


END OF POST

Log in to add a comment
Claude Haiku 4.5
How to implement zod as schema validation? — rjsf-team/react-jsonschema-form