Validator, Errors and DataBinder

BeanValidator checks an object against its constraint annotations, Errors holds what went wrong, and DataBinder fills an object from text input before checking it.

Why you need them

The constraint annotations only describe rules. Something has to run them and give you the result in a form you can use:

You do not need to call them yourself for @Valid() request bodies, @Validated() beans and @Validated() configuration properties: the framework does it.

BeanValidator

BeanValidator reads the constraints of @Introspected() classes. Create it with the context's BeanIntrospector: the default introspector, BeanIntrospector.shared, does not know the classes of your application modules.

import 'package:spring_core_dart/spring_core_dart.dart';

const log = SpringLog('import');

@Service()
class CustomerImport {
  // The context registers its BeanIntrospector as a bean.
  CustomerImport(BeanIntrospector introspector)
    : _validator = BeanValidator(introspector: introspector);

  final BeanValidator _validator;

  // Rows from a file do not pass through a controller: check them here.
  bool accept(CustomerRow row) {
    final violations = _validator.validateObject(row);
    for (final v in violations) {
      log.warning('${v.propertyPath}: ${v.message}'); // name: must not be blank
    }
    return violations.isEmpty;
  }
}

With spring_boot_dart, a BeanValidator bean named defaultValidator already exists. Inject BeanValidator or Validator instead of creating one.

Constructor

Name Type Default Meaning
introspector BeanIntrospector? BeanIntrospector.shared Describes the classes. Pass the context's.
validationMessageSource MessageSource? null Resolves {key} placeholders in constraint messages, such as @NotBlank(message: '{signup.name.missing}').
clockProvider ClockProvider DefaultClockProvider() Gives "now" to the date constraints.
groupSequences Map<Type, List<Type>> const {} Group sequences: each key group stands for the listed groups, checked in order until one has a violation.

Methods

Method Returns Meaning
validateObject(target, {groups}) List<ConstraintViolation> Every violation of target, including those inside @Valid() properties. Empty when valid or not @Introspected.
validateProperty(target, property, {groups}) List<ConstraintViolation> The violations of one property, without @Valid() cascade. Useful to check one form field.
validateValue(type, property, value, {groups}) List<ConstraintViolation> The violations value would cause as property of class type, without an instance.
validateParameters(target, method, arguments, {groups}) List<ConstraintViolation> Used by method validation; applications rarely call it.
validateReturnValue(target, method, returnValue, {groups}) List<ConstraintViolation> Used by method validation; applications rarely call it.
validate(target, errors) void As a Validator: records each violation as a FieldError in errors.
validateWithGroups(target, errors, groups) void As a SmartValidator: the same, for some groups only.
// Check one field as the user leaves it.
final nameProblems = validator.validateProperty(form, 'name');

// Check input before an object exists.
final ageProblems = validator.validateValue(SignupForm, 'age', 12);

ConstraintViolation

Member Type Meaning
propertyPath String Where the invalid value is: name, address.city, items[0].sku. Empty for a class-level constraint.
constraint Constraint The constraint that failed; constraint.name is the error code.
invalidValue Object? The value that broke the constraint.
message String The finished message, such as size must be between 0 and 20.
messageTemplate String? The message before interpolation.
rootBean Object? The object validation started from.
leafBean Object? The object that holds the invalid property.

Messages

Messages are filled in this order:

  1. {key} placeholders are looked up in validationMessageSource. The built-in messages are also available under jakarta.validation.constraints.<Name>.message, so a message source can replace them.
  2. {attribute} placeholders take the constraint's attributes ({min}, {max}, {value}).
  3. ${validatedValue} takes the invalid value.

\{, \}, \$ and \\ stay literal characters.

// Replace the built-in NotBlank text and use a key in a custom message.
final messages = StaticMessageSource()
  ..addMessage('jakarta.validation.constraints.NotBlank.message', '', 'please fill in')
  ..addMessage('nick.size', '', 'at least {min} letters');

final validator = BeanValidator(
  introspector: introspector,
  validationMessageSource: messages,
);

You do not need validationMessageSource for keys such as NotBlank.signup.name in messages.properties. Those word field errors when they are shown through the application's message source.

Validator: rules in code

Write a Validator for rules that do not fit an annotation: a value that depends on another one, or a lookup. It receives the object and records problems in an Errors.

// Two fields must agree: written in code instead of as an annotation.
class SignupValidator implements Validator {
  @override
  bool supports(Object target) => target is SignupForm;

  @override
  void validate(Object target, Errors errors) {
    ValidationUtils.rejectIfEmptyOrWhitespace(errors, 'name', 'required');
    final form = target as SignupForm;
    if (form.password != form.confirm) {
      errors.rejectValue('confirm', 'mismatch');
    }
  }
}
Member Meaning
supports(Object target) Whether this validator can check target. It receives the object, not its class.
validate(Object target, Errors errors) Records every problem in errors. Does not throw for invalid data.

SmartValidator adds validateWithGroups(target, errors, groups). BeanValidator implements it.

If you register a Validator as a bean, the web starter checks @Valid() arguments with the single Validator bean. With a second one and neither marked @Primary, those arguments are not checked.

ValidationUtils

Method Meaning
invokeValidator(validator, target, errors, {groups}) Runs validator. Throws ArgumentError when it does not support target. Passes groups to a SmartValidator.
rejectIfEmpty(errors, field, errorCode, {args, defaultMessage}) Records an error when the field is null, '', or an empty collection or map.
rejectIfEmptyOrWhitespace(errors, field, errorCode, {args, defaultMessage}) The same, and also rejects a string of only whitespace.

Errors and BindingResult

Errors is the list of errors for one object, and the way a validator records them. BindingResult adds the target object. The usual implementations are BeanPropertyBindingResult(target, objectName) and MapBindingResult(map, objectName).

// Run two validators into one result and read it.
final result = BeanPropertyBindingResult(form, 'signup');
ValidationUtils.invokeValidator(validator, form, result);
ValidationUtils.invokeValidator(SignupValidator(), form, result);

if (result.hasErrors) {
  for (final error in result.fieldErrors()) {
    // NotBlank.signup.name, NotBlank.name, NotBlank.String, NotBlank
    log.info('${error.field}: ${error.codes}');
  }
}

Recording errors

Method Meaning
reject(errorCode, {args, defaultMessage, renderDefaultMessage}) An error of the whole object (an ObjectError).
rejectValue(field, errorCode, {args, defaultMessage, renderDefaultMessage}) An error of one field (a FieldError); the field's current value becomes the rejected value.
pushNestedPath(subPath) / popNestedPath() Name fields relative to a nested object, so one address validator serves billing.city and shipping.city.
addAllErrors(errors) Copies the errors of another result with the same object name.

Reading errors

Member Meaning
hasErrors, errorCount, allErrors Every error, of fields and of the object.
hasGlobalErrors, globalErrors, globalError Errors of the whole object.
hasFieldErrors([field]), fieldErrors([field]), fieldError([field]) Field errors; address.* matches every field under a path.
getFieldValue(field) The value to show in a form: the rejected value if the field has an error, else the current value.

ObjectError and FieldError

An error has no fixed text. It carries message codes, from the most specific to the plain one, plus arguments and a default message. A MessageSource turns it into text with the first code it defines.

Member Type Meaning
objectName String Name of the validated object, such as signup.
codes List<String> Message codes, most specific first.
code String The last, plain code, such as NotBlank.
arguments List<Object?> Values for {0}, {1}. For constraints: the field, then the attributes sorted by name.
defaultMessage String? Text when no code has a message.
field (FieldError) String Property path of the field.
rejectedValue (FieldError) Object? The value that was rejected.
bindingFailure (FieldError) bool true when the input could not be converted. Such a field gets no constraint error on top.
// Word an error with the application's messages.properties:
// Min={0} must be at least {1}
final error = result.fieldError('age')!;
final text = context.getMessageFromResolvable(error); // age must be at least 18

Codes follow Spring's scheme. For the constraint NotBlank on field name of object signup, of type String: NotBlank.signup.name, NotBlank.name, NotBlank.String, NotBlank. DefaultMessageCodesResolver(prefix: 'validation.', postfixFormat: true) changes the scheme; implement MessageCodesResolver to use your own.

DataBinder

DataBinder copies input values, usually strings, onto the properties of an @Introspected() object, then runs its validators. Every problem ends up in one bindingResult. Text that cannot be converted becomes a typeMismatch field error that keeps what was typed, instead of an exception.

Use it when the input is a flat map of strings: form fields, query parameters, a CSV row. JSON request bodies do not need it; they are read by the ObjectMapper and checked with @Valid().

// A signup form: `email` is required, the client may never set `id`.
final introspector = context.getBean<BeanIntrospector>();
final binder = DataBinder(SignupForm(), objectName: 'signup', introspector: introspector)
  ..requiredFields = ['email']
  ..disallowedFields = ['id']
  ..validators.add(BeanValidator(introspector: introspector))
  ..bind({'name': 'Ada', 'age': 'old', 'address.city': 'Oslo'})
  ..validate();

final result = binder.bindingResult;
result.fieldError('age')!.codes.first; // typeMismatch.signup.age
result.fieldError('email')!.code; // required
binder.suppressedFields; // fields skipped by allowed/disallowed rules

close() ends binding: it throws a BindException when there are errors, else returns a model map with the target under objectName and the result under BindingResult.<objectName>.

Constructor

Name Type Default Meaning
target (positional) Object? required The object to bind onto; null when construct creates it.
objectName String 'target' Name in error codes and the model.
introspector BeanIntrospector? BeanIntrospector.shared Describes the target's class. Pass the context's.
conversionService ConversionService? DefaultConversionService.sharedInstance Converts input strings to property types.
messageCodesResolver MessageCodesResolver DefaultMessageCodesResolver() Builds error codes.

Settings

Name Type Default Meaning
allowedFields List<String>? null Fields that may be bound, with * wildcards. null or empty allows every field.
disallowedFields List<String> const [] Fields never bound, such as id or *.admin. They win over allowedFields. Matching ignores case.
requiredFields List<String> const [] Fields the input must contain with a non-blank value; a missing one gets a required error.
ignoreUnknownFields bool true Skip input for fields the target does not have. When false, bind throws.
ignoreInvalidFields bool false Skip input for paths that cannot be reached. When false, bind throws.
autoGrowNestedPaths bool true Create null nested objects, lists and maps while binding address.city or items[2].sku.
autoGrowCollectionLimit int 256 Highest list index plus one that binding may create.
validators List<Validator> empty The validators validate runs, in order. Nothing is checked until you add one.
targetType Type? null The class construct creates.

Methods

Method Meaning
bind(Map<String, Object?> values) Sets properties from values whose keys are paths (name, address.city, items[0].sku).
validate({groups}) Runs each validator that supports the target. A field with a typeMismatch error gets no constraint error on top.
construct(ValueResolver valueResolver) Creates the target through its constructor, for classes with final fields.
close() Throws BindException on errors, else returns the model map.

Immutable classes

A class with final fields and no setters cannot be bound onto. Create it from the values with construct instead:

// Start without a target; construct calls Book's constructor.
final binder = DataBinder(null, objectName: 'book', introspector: introspector)
  ..targetType = Book
  ..construct(MapValueResolver({'isbn': '978-0', 'title': 'Dune'}));

final book = binder.target as Book?; // null when a value was invalid

In Spring (Java)

LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean();
validator.afterPropertiesSet();

DataBinder binder = new DataBinder(form, "signup");
binder.setRequiredFields("email");
binder.setDisallowedFields("id");
binder.setValidator(validator);
binder.bind(new MutablePropertyValues(Map.of("name", "Ada", "age", "17")));
binder.validate();
BindingResult result = binder.getBindingResult();

Differences that matter:

See also