Skip to content

Cross-Field Validation

Cross-field validation checks values across multiple fields. The form system supports this through the ContextAwareValidation mixin, which provides reactive cross-field subscriptions.

How It Works

  1. A validation implements the ContextAwareValidation mixin
  2. It declares which fields it depends on via getDependentFields()
  3. The form wires subscriptions: when a dependent field changes, the target field re-validates
  4. At validation time, validateWithContext() receives the full form values map

DateDependencyValidation

The built-in cross-field date validator:

dart
// End date must be after start date
DateTimeField(
  name: 'end_date',
  title: 'Course End Date',
  selectionType: SelectionType.date,
  validations: [
    RequiredValidation(errorMessage: 'End date is required'),
    DateDependencyValidation(
      dependentFieldName: 'start_date',
      operator: DateComparisonOperator.after,
      errorMessage: 'End date must be after start date',
    ),
  ],
)

With Offset

dart
// End date must be at least 7 days after start date
DateDependencyValidation(
  dependentFieldName: 'start_date',
  operator: DateComparisonOperator.afterOrEqual,
  offsetDays: 7,
  errorMessage: 'Course must run at least 7 days',
)

Comparison Operators

OperatorDescription
beforeMust be before the dependent field
afterMust be after the dependent field
beforeOrEqualMust be before or same day
afterOrEqualMust be after or same day
sameDayMust be the same day
differentDayMust be a different day

LMS Example: Course Schedule

dart
final form = FormBuilder(title: 'Course Schedule')
  .dateTime('registration_open', title: 'Registration Opens')
    .dateOnly()
    .required()
  .dateTime('registration_close', title: 'Registration Closes')
    .dateOnly()
    .required()
  .dateTime('course_start', title: 'Course Start')
    .dateOnly()
    .required()
  .dateTime('course_end', title: 'Course End')
    .dateOnly()
    .required()
  .build();

With cross-field validations added via constructors:

dart
DateTimeField(
  name: 'registration_close',
  title: 'Registration Closes',
  selectionType: SelectionType.date,
  validations: [
    RequiredValidation(errorMessage: 'Required'),
    DateDependencyValidation(
      dependentFieldName: 'registration_open',
      operator: DateComparisonOperator.after,
      errorMessage: 'Must be after registration opens',
    ),
  ],
),

DateTimeField(
  name: 'course_start',
  title: 'Course Start',
  selectionType: SelectionType.date,
  validations: [
    RequiredValidation(errorMessage: 'Required'),
    DateDependencyValidation(
      dependentFieldName: 'registration_close',
      operator: DateComparisonOperator.afterOrEqual,
      offsetDays: 1,
      errorMessage: 'Must be at least 1 day after registration closes',
    ),
  ],
),

DateTimeField(
  name: 'course_end',
  title: 'Course End',
  selectionType: SelectionType.date,
  validations: [
    RequiredValidation(errorMessage: 'Required'),
    DateDependencyValidation(
      dependentFieldName: 'course_start',
      operator: DateComparisonOperator.afterOrEqual,
      offsetDays: 7,
      errorMessage: 'Course must run at least 7 days',
    ),
  ],
),

When the user changes "Registration Opens", the "Registration Closes" field re-validates automatically. The chain continues through "Course Start" and "Course End".

Custom Context-Aware Validation

To create your own cross-field validator:

dart
@JsonSerializable()
class PasswordMatchValidation extends ValidationConfiguration
    with ContextAwareValidation {
  static const schemaName = 'formfield.validation.passwordMatch';
  static final typeDescriptor = TypeDescriptor(
    schemaType: schemaName,
    title: 'Password Match',
    fromJson: PasswordMatchValidation.fromJson,
  );

  final String passwordFieldName;

  PasswordMatchValidation({
    required this.passwordFieldName,
    required super.errorMessage,
  }) : super(schemaType: schemaName, title: 'Password Match');

  factory PasswordMatchValidation.fromJson(Map<String, dynamic> json) =>
      _$PasswordMatchValidationFromJson(json);

  @override
  String? validate<T>(T? value) => null; // Handled by validateWithContext

  @override
  Set<String> getDependentFields() => {passwordFieldName};

  @override
  String? validateWithContext<T>(T? value, Map<String, dynamic> formValues) {
    if (value == null) return null;
    final password = formValues[passwordFieldName]?.toString();
    if (password == null) return null;
    return value.toString() == password ? null : errorMessage;
  }
}

Next Steps