How to implement schema-driven dynamic validation?

main
12882d9Fix nested if/then conditions losing values with liveOmit and omitExtraData (#4920)
This post might have stale content, as main is 50 commits ahead.

How can I schema-driven dynamic validation. As known, 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 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 based on a field (province, city, country,...)

Avatar of anthropic/claude-haiku-4.5
Claude Haiku 4.5
commented

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


##Schema-Driven Dynamic Validation in react-jsonschema-form

Based on the source code, I can show you how to implement schema-driven dynamic validation that addresses your use case of unknown schemas from the server. The key is understanding how customValidate works and how to make it dynamic.

###The Core Mechanism: customValidate

The customValidate function is the primary mechanism for cross-field validation in react-jsonschema-form. Here's the type definition from the source:

export type CustomValidator<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends FormContextType = any> = (
  formData: T | undefined,
  errors: FormValidation<T>,
  uiSchema?: UiSchema<T, S, F>,
  errorSchema?: ErrorSchema<T>,
) => FormValidation<T>;

Key Points:

  • formData: The entire form data object (with all fields including undefined values)
  • errors: A FormValidation object that mirrors your form's structure, with an addError() method for each field
  • uiSchema: Can be used to derive additional validation context
  • errorSchema: Contains errors from JSON Schema validation - useful for conditional custom validation

###Solution for Schema-Driven Dynamic Validation

Here's how to implement dynamic validation for unknown schemas from the server:

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

/**
 * Schema-driven custom validator that reads validation rules from the schema itself
 */
const dynamicCustomValidate: CustomValidator = (formData, errors, uiSchema, errorSchema) => {
  // Get the schema from somewhere (can be part of formContext or passed via closure)
  // This allows you to define validation rules in your schema
  
  if (!formData) return errors;

  // Example 1: Validate Date of Birth (must be older than 18)
  if (formData.dateOfBirth) {
    const age = calculateAge(new Date(formData.dateOfBirth));
    if (age < 18) {
      errors.dateOfBirth?.addError('Must be at least 18 years old');
    }
  }

  // Example 2: Cross-field date range validation (from date < to date)
  if (formData.fromDate && formData.toDate) {
    const fromDate = new Date(formData.fromDate);
    const toDate = new Date(formData.toDate);
    
    if (fromDate >= toDate) {
      errors.fromDate?.addError('From date must be before To date');
    }
  }

  // Example 3: Dependent field validation (city depends on province)
  if (formData.province && formData.city) {
    const validCities = getValidCities(formData.province);
    if (!validCities.includes(formData.city)) {
      errors.city?.addError(`${formData.city} is not valid for province ${formData.province}`);
    }
  }

  return errors;
};

function calculateAge(birthDate: Date): number {
  const today = new Date();
  let age = today.getFullYear() - birthDate.getFullYear();
  const monthDiff = today.getMonth() - birthDate.getMonth();
  if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
    age--;
  }
  return age;
}

function getValidCities(province: string): string[] {
  // This could come from your API/database
  const cityMap: Record<string, string[]> = {
    'Ontario': ['Toronto', 'Ottawa', 'Hamilton'],
    'Quebec': ['Montreal', 'Quebec City', 'Gatineau'],
    // ... etc
  };
  return cityMap[province] || [];
}

// Usage
const schema: RJSFSchema = {
  type: 'object',
  properties: {
    dateOfBirth: { type: 'string', format: 'date' },
    fromDate: { type: 'string', format: 'date' },
    toDate: { type: 'string', format: 'date' },
    province: { type: 'string', enum: ['Ontario', 'Quebec'] },
    city: { type: 'string' },
  },
};

render(
  <Form 
    schema={schema} 
    validator={validator} 
    customValidate={dynamicCustomValidate}
  />,
  document.getElementById('app')
);

###Advanced: Schema-Driven Validation Rules

For truly dynamic schemas from the server, you can embed validation rules in the schema itself using custom properties:

const serverSchema: RJSFSchema = {
  type: 'object',
  properties: {
    dateOfBirth: {
      type: 'string',
      format: 'date',
      'x-validate': 'minAge:18', // Custom validation rule
    },
    fromDate: {
      type: 'string',
      format: 'date',
      'x-validate': 'before:toDate', // Custom validation rule
    },
    toDate: {
      type: 'string',
      format: 'date',
    },
    province: {
      type: 'string',
      'x-validate': 'dependsOn:city', // Custom validation rule
    },
    city: {
      type: 'string',
      'x-validate': 'filteredBy:province', // Custom validation rule
    },
  },
};

/**
 * Generic schema-driven validator that interprets custom validation rules from schema
 */
function createDynamicValidator(schema: RJSFSchema): CustomValidator {
  return (formData, errors) => {
    if (!formData) return errors;

    // Iterate through schema properties and apply custom validations
    Object.entries(schema.properties || {}).forEach(([fieldName, fieldSchema]: [string, any]) => {
      const validationRule = fieldSchema['x-validate'];
      if (!validationRule) return;

      const fieldValue = formData[fieldName];
      const fieldErrors = errors[fieldName];

      // Parse and apply validation rules
      if (validationRule.startsWith('minAge:')) {
        const minAge = parseInt(validationRule.split(':')[1]);
        const age = calculateAge(new Date(fieldValue));
        if (age < minAge) {
          fieldErrors?.addError(`Must be at least ${minAge} years old`);
        }
      } 
      else if (validationRule.startsWith('before:')) {
        const beforeField = validationRule.split(':')[1];
        const beforeValue = formData[beforeField];
        if (new Date(fieldValue) >= new Date(beforeValue)) {
          fieldErrors?.addError(`Must be before ${beforeField}`);
        }
      }
      else if (validationRule.startsWith('filteredBy:')) {
        const dependsOnField = validationRule.split(':')[1];
        const dependsOnValue = formData[dependsOnField];
        const validValues = getValidCities(dependsOnValue);
        if (!validValues.includes(fieldValue)) {
          fieldErrors?.addError(`Not valid for ${dependsOnField}: ${dependsOnValue}`);
        }
      }
    });

    return errors;
  };
}

// Usage with dynamic schema
const schema = await fetch('/api/schema').then(r => r.json());
const customValidate = createDynamicValidator(schema);

render(
  <Form 
    schema={schema} 
    validator={validator} 
    customValidate={customValidate}
  />,
  document.getElementById('app')
);

###Accessing the Schema Inside customValidate

If you need access to the schema within your validator, you have several options:

Option 1: Use a closure (recommended for unknown schemas)

function createValidatorForSchema(schema: RJSFSchema): CustomValidator {
  return (formData, errors, uiSchema, errorSchema) => {
    // Schema is accessible here via closure
    // Apply validation rules based on schema
    return errors;
  };
}

const customValidate = createValidatorForSchema(dynamicSchema);

Option 2: Store schema in formContext

<Form 
  schema={schema} 
  validator={validator}
  formContext={{ schema }}
  customValidate={(formData, errors, uiSchema, errorSchema) => {
    // Access via formContext if needed
    return errors;
  }}
/>

Option 3: Use the errorSchema parameter The fourth parameter errorSchema contains the results of JSON Schema validation, which can help you determine which fields need additional validation:

const customValidate: CustomValidator = (formData, errors, uiSchema, errorSchema) => {
  // Only apply custom validation if JSON schema validation passed
  if (!errorSchema?.['dateOfBirth']?.__errors?.length) {
    // Perform additional custom validation
  }
  return errors;
};

###Key Advantages

  1. Handles unknown schemas: Uses data-driven validation rules embedded in the schema
  2. Cross-field validation: Full access to all form data for complex validations
  3. Runs after schema validation: Complements JSON Schema validation without replacing it
  4. Flexible error handling: Can add errors to any field path in the form structure
  5. Performance: Runs synchronously; consider async validation if needed

###Source Code References

  • CustomValidator type: packages/utils/src/types.ts (line 1155)
  • Error handling: packages/utils/src/createErrorHandler.ts
  • Validation processing: packages/validator-ajv8/src/processRawValidationErrors.ts
  • Playground example: packages/playground/src/samples/validation.ts

This approach gives you the flexibility to handle any validation scenario with unknown schemas from the server!


END OF POST

How to implement schema-driven dynamic validation? — rjsf-team/react-jsonschema-form