Skip to content

How validators work

A validator is an immutable object that holds a type check and an ordered list of validations, and produces a typed output when a value passes. This page explains the model behind the API so the reference pages make sense.

l is the validator factory. Its type methods each start a validator with one type check: string(), int(), double(), num(), bool(), list(), map(), schema(), union(), oneOf(), file(), any() and nullValue(). Type methods exist only on l, so l.string().int() does not compile. Each returns a validator class with the modifiers that fit its type.

l.string(); // StringValidator: .email(), .min(), .regex(), and the other string modifiers
l.int(); // NumberValidator<int, int?>: .min(), .max(), .finite()
l.schema({}); // SchemaValidator: .passthrough(), .strict(), validateSchema()
l.list(l.string()); // ListValidator: only the shared modifiers

Every validator has the shared modifiers required(), withName(), custom() and customWithSchema(). l.nullValue() is the exception: it has only withName().

A modifier returns a new validator with one more validation appended, and the return type is the same validator class, so modifiers chain in any order. l.string().required().min(3) and l.string().min(3).required() are the same validator.

[value must be at least 3 characters long]
[value must be at least 3 characters long]

Validations run in chain order, and every validation after the type check runs, so one failed input can carry several messages. The type check is the one exception: when it fails, nothing else runs.

l.string().min(8).contains('@').validate('ab').messages
[value must be at least 8 characters long, value does not contain "@"]
l.string().email().validate(42).messages
[value must be a string]

The type parameter of a validator is its output type. l.string() is a StringValidator<String?>, and .required() narrows it to StringValidator<String>. On success, data has that type, so you get a String or a List<int> without a cast or any generated code.

final count = l.int().required().validate(42);
if (count case ValidationSuccess(:final data)) {
print(data.runtimeType); // data is an int
}
final tags = l.list(l.string().required()).required().validate(['a']);
if (tags case ValidationSuccess(:final data)) {
print(data.runtimeType); // data is a List<String>
}
int
List<String>

Schemas, lists and maps produce a new collection of the validated values rather than returning the input. Schemas covers what that means for unknown keys.

Chaining never changes the validator you chained from. A base validator can be shared and extended in several places without the extensions affecting each other. Define common validators once and reuse them across schemas.

final text = l.string();
final email = text.email();
final uuid = text.uuid();
text.validate('anything').isValid;
email.validate('anything').isValid;
uuid.validate('anything').isValid;
true
false
false

Default messages start with a field name. Validated on its own, a validator has none and messages say value. withName() sets one. Inside a schema, the field key is the name, and withName on the field’s validator is ignored.

l.string().email().validate('bad').messages.first;
l.string().email().withName('email').validate('bad').messages.first;
l.schema({'email': l.string().email()}).validate({'email': 'bad'}).errors;
value must be a valid email address
email must be a valid email address
{email: [email must be a valid email address]}

Every generated schema ends in .withName('ModelName'), which names the model in the must be a map message when the input is not a map.

Every type and modifier accepts message, a fixed string, and messageBuilder, a function that receives the issue and returns the string. l.messageBuilder sets one builder for every message. Message builders covers all three.