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.
Custom validator
Section titled “Custom validator”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');bool isEven(int value) => value.isEven;
@luthor@freezedabstract class Ticket with _$Ticket { const factory Ticket({ @WithCustomValidator(isEven, message: 'must be even') required int seat, }) = _Ticket;
factory Ticket.fromJson(Map<String, dynamic> json) => _$TicketFromJson(json);}The function has to be a top-level function or a static method, with the field’s type as its parameter. Generated code refers to it by name, so a closure cannot be used.
evenNumber.validate(input)+2-2- valid:
4 - invalid:
3 - valid:
null - invalid:
'4'
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]Schema custom validator
Section titled “Schema custom validator”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(),});bool matchesPassword(String value, SchemaData data) => value == data['password'];
@luthor@freezedabstract class SignUp with _$SignUp { const factory SignUp({ @HasMin(8) required String password, @WithSchemaCustomValidator(matchesPassword, message: 'Passwords must match') required String confirm, }) = _SignUp;
factory SignUp.fromJson(Map<String, dynamic> json) => _$SignUpFromJson(json);}signUp.validate(input)+1-2- valid:
{'password': 'hunter2hunter2', 'confirm': 'hunter2hunter2'} - invalid:
{'password': 'hunter2hunter2', 'confirm': 'nope'} - invalid:
{'password': 'hunter2hunter2'}
Sibling values are raw input
Section titled “Sibling values are raw input”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 rule that depends on another field
Section titled “A rule that depends on another field”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- valid:
{'accountType': 'personal'} - valid:
{'accountType': 'business', 'phone': '+44 20 7946 0958'} - invalid:
{'accountType': 'business'}
Reaching the root
Section titled “Reaching the root”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:
trueRules that apply to both
Section titled “Rules that apply to both”- The function receives the typed value and is never called for
null. Decide separately whethernullis 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.
messageandmessageBuilderwork as on every other modifier. The code iscustomfor 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.