Skip to content

Custom validators

Two modifiers accept a function of your own. .custom() sees one value. .customWithSchema() sees one value and the schema data around it. Both receive the validator’s typed value, run only after the type check passed, are never called with null, and treat a thrown exception as false.

A custom validator is a bool Function(T value), where T is the validator’s value type: int for l.int(), String for l.string(), Map<String, Object?> for a schema. Attach it with .custom().

bool isEven(int value) => value.isEven;
final evenNumber = l.int().custom(isEven, message: 'must be even');
evenNumber.validate(input)+2-2
  1. valid: 4
  2. invalid: 3must be even
  3. valid: null
  4. invalid: '4'value must be an integer

Two rows explain the rules. null passes because the validator is optional and a custom validator is never called for null; add .required() to reject it. '4' fails the type check, and the custom validator does not run, so there is one message, not two.

Without message, the default is value does not pass custom validation, and a function that throws produces the same message:

[value does not pass custom validation]
[value does not pass custom validation]

A schema custom validator is a bool Function(T value, SchemaData data). Attach it with .customWithSchema(). data is a read-only map of the sibling fields in the schema that holds the value, as they were received, and data.root is the outermost schema’s map. The usual case is a confirmation field.

bool matchesPassword(String value, SchemaData data) =>
value == data['password'];
final signUp = l.schema({
'password': l.string().min(8).required(),
'confirm': l
.string()
.customWithSchema(matchesPassword, message: 'Passwords must match')
.required(),
});
signUp.validate(input)+1-2
  1. valid: {'password': 'hunter2hunter2', 'confirm': 'hunter2hunter2'}
  2. invalid: {'password': 'hunter2hunter2', 'confirm': 'nope'}confirm: Passwords must match
  3. invalid: {'password': 'hunter2hunter2'}confirm: confirm is required

data holds the input, not the validated output. A sibling field can be missing, null, or of a type its own validator rejects, and it still appears in data with that raw value when its validator fails in the same validation. Comparing with ==, as matchesPassword does, is safe with any value. Before you use a sibling as a type, check it with is:

bool atMostLimit(int value, SchemaData data) {
final limit = data['limit'];
if (limit is! int) return true;
return value <= limit;
}
final quota = l.schema({
'limit': l.int().required(),
'used': l
.int()
.customWithSchema(atMostLimit, message: 'used must not exceed limit')
.required(),
});

When limit is not an int, its own validator reports that, so atMostLimit passes instead of adding a second error at used. A cast such as data['limit'] as int would throw for {'limit': 'ten', 'used': 3}, and a thrown exception counts as false, so used would report a misleading error next to the real one at limit. To compare validated values, use .custom() on the schema, which runs only when every field passed.

A missing optional field is skipped before any validator runs, so a rule such as “a business account needs a phone number” cannot live on phone. Put it on accountType, which is always present, and read phone from data.

bool phoneForBusiness(String value, SchemaData data) {
if (value != 'business') return true;
final phone = data['phone'];
return phone is String && phone.isNotEmpty;
}
final account = l.schema({
'accountType': l
.oneOf(['personal', 'business'])
.customWithSchema(phoneForBusiness, message: 'A business account needs a phone number')
.required(),
'phone': l.string(),
});
account.validate(input)+2-1
  1. valid: {'accountType': 'personal'}
  2. valid: {'accountType': 'business', 'phone': '+44 20 7946 0958'}
  3. invalid: {'accountType': 'business'}accountType: A business account needs a phone number

Inside a list or a nested schema, data is the nearest schema’s map. data.root is the outermost one, so a line item can check the order it belongs to.

final order = l.schema({
'currency': l.string().required(),
'lines': l.list(l.schema({
'amount': l.num().required(),
'currency': l
.string()
.customWithSchema((value, data) => value == data.root['currency'],
message: 'line currency must match the order currency')
.required(),
}).required()),
});
{lines.1.currency: [line currency must match the order currency]}

Outside any schema, data is empty. The function still runs:

l.string().customWithSchema((v, data) => data.isEmpty).validate('x').isValid
true
  • The function receives the typed value and is never called for null. Decide separately whether null is allowed, with .required().
  • A thrown exception counts as false. Nothing is printed.
  • On a schema, list, map or union, the function runs only when every child passed, so it can trust the shape.
  • Keep the function pure. It can run more than once for one input, for example inside a list.
  • message and messageBuilder work as on every other modifier. The code is custom for both.
  • For a rule about the whole object rather than one field, use .custom() on the schema; it reports at the schema’s own path. Schemas shows it.