Skip to content

Schemas

A schema is a validator built with l.schema(). It checks that a value is a map, validates each named field with its own validator, and produces a new map of the validated values. This page covers the ways schemas combine. Your first schema is the tutorial; l.schema() is the reference.

The argument to l.schema() is a Map<String, ValidatorReference<Object?>>. Each entry is a field: the key is the name expected in the input, the value is the validator for that field. The output map holds every field that was present in the input, with its validated value.

final address = l.schema({
'street': l.string().required(),
'city': l.string().required(),
'postcode': l.string().regex(RegExp(r'^\d{5}$')).required(),
});

A schema is a validator, so it can be the validator of a field. Define a schema once and use it in several places. Each use can carry its own .required().

final person = l.schema({
'name': l.string().required(),
'home': address.required(),
'work': address,
});
person.validate({'name': 'Ada', 'home': {'street': '1 Main St', 'city': 'Paris', 'postcode': '12'}}).errors;
person.validate(...).getError('home.postcode');
{home.postcode: [postcode must match the pattern ^\d{5}$]}
postcode must match the pattern ^\d{5}$

Errors from a nested schema arrive at a dotted path in the flat error map. Errors and error paths describes the shape.

By default a schema strips keys it does not declare from the output. That protects a fromJson from input it did not ask for, and keeps the output to the shape you described. Two modifiers change it: .passthrough() copies unknown keys into the output unvalidated, and .strict() reports each one as an unrecognizedKey issue at its path.

final user = l.schema({'name': l.string().required()});
const input = {'name': 'Ada', 'role': 'admin'};
user.validate(input);
user.passthrough().validate(input);
user.strict().validate(input);
strip, the default
ValidationSuccess(data: {name: Ada})
passthrough
ValidationSuccess(data: {name: Ada, role: admin})
strict
ValidationFailure(errors: {role: [role is not an allowed key]})
role is not an allowed key

Generated schemas strip too, and declare every key the serializer reads, so a freezed or json_serializable fromJson sees what it expects. What gets generated covers the cases where the generator adds .passthrough() instead.

A .custom() on the schema itself receives the validated output map, runs only when every field passed, and reports at the schema’s own path: '' at the root. Use it for rules that involve several fields and belong to the object rather than to one of them.

bool endAfterStart(Map<String, Object?> data) {
final start = DateTime.tryParse('${data['start']}');
final end = DateTime.tryParse('${data['end']}');
return start != null && end != null && end.isAfter(start);
}
final dateRange = l.schema({
'start': l.string().dateTime().required(),
'end': l.string().dateTime().required(),
}).custom(endAfterStart, message: 'end must be after start');
dateRange.validate(input)+1-2
  1. valid: {'start': '2026-01-01', 'end': '2026-02-01'}
  2. invalid: {'start': '2026-02-01', 'end': '2026-01-01'}end must be after start
  3. invalid: {'start': 'yesterday', 'end': '2026-01-01'}start: start must be a valid date

The third row shows the rule not running: a field failed, so the custom validator was skipped and only the field error is reported. Read the object-level message with getError('').

dateRange.validate({...}).getError('')
end must be after start

To attach a rule to one field and read other fields, use .customWithSchema() instead.

validateSchema takes a fromJson and runs it on the output map after every field passed. If fromJson throws, the result is a failure with a fromJsonFailed issue rather than an exception.

final result = user.validateSchema(json, fromJson: User.fromJson);

Without fromJson, use validate, whose success data is the Map<String, Object?> output.

The schema reports one invalidType issue. message and messageBuilder on l.schema() apply to this case only. A nested schema uses its field key as the field name.

[value must be a map]
[send an object]
{profile: [profile must be a map]}

A tree node whose children are tree nodes needs its own validator while it is still being defined. A plain reference does not work: the variable has no value yet. Wrap the reference in forwardRef(), which takes a function and calls it at validation time, when the variable is assigned.

late final SchemaValidator node;
node = l.schema({
'value': l.string().required(),
'children': l.list(forwardRef(() => node.required())),
});
node.validate(input)+1-1
  1. valid: {'value': 'root', 'children': [{'value': 'leaf'}]}
  2. invalid: {'value': 'root', 'children': [{'value': 'a', 'children': [{'value': 1}]}]}children.0.children.0.value: value must be a string

Every level is validated. The failing grandchild above is reported at children.0.children.0.value. Two schemas that refer to each other follow the same pattern: declare both as late final and wrap each reference to the other. Recursion is bounded by the input and by l.maxDepth, so deeply nested input fails with a tooDeep issue instead of overflowing the stack.

The generator wraps every reference to another model’s schema in forwardRef(), so recursive models need no annotation. forwardRef() has the details.