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:
- a list of violations with finished messages, for logs or your own response format:
BeanValidator.validateObject; - an
Errorsobject with message codes, so a message file can word each error in the user's language:BeanValidatoras aValidator; - a full round trip from form fields or query parameters (strings) to a checked object:
DataBinder.
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:
{key}placeholders are looked up invalidationMessageSource. The built-in messages are also available underjakarta.validation.constraints.<Name>.message, so a message source can replace them.{attribute}placeholders take the constraint's attributes ({min},{max},{value}).${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:
BeanValidatorplays both roles ofLocalValidatorFactoryBean: Jakarta'svalidator.validate(object)isvalidateObject, and Spring'svalidate(target, errors)isvalidate.Validator.supportsreceives the object, not aClass, because Dart cannot test aTypefor subtypes.DataBinder.bindtakes aMap<String, Object?>instead ofPropertyValues, and settings are fields (requiredFields = [...]) instead of setters.- Validation groups are passed as
groups:instead of validation hints. - Classes must be
@Introspected(); there is no reflection.