livr
Fast, zero-dependency Dart validator for the LIVR specification (Language Independent Validation Rules, v2.0). Rules are plain data: store them in JSON, send them over the wire and validate the same way, with the same error codes, in Dart, JavaScript, Perl, Python, Go and other languages.
- Returns only the fields that have rules, everything else is dropped.
- Reports errors for all fields at once, as codes shaped like the data.
- Cleans the data on the way: trims strings, converts
"25"to25, sets defaults. - Passes the whole LIVR test suite, on the Dart VM, AOT and the web.
Install
dart pub add livr
Quick start
A validator needs the schema and the type of its output. Describe the type by hand, for example as an extension type over the output map: it costs nothing at run time, the output is not copied.
import 'package:livr/livr.dart';
extension type SignUp(Map<String, Object?> _) {
String get name => _['name'] as String;
String get email => _['email'] as String;
String? get gender => _['gender'] as String?;
int get age => _['age'] as int;
String get password => _['password'] as String;
}
final signUpValidator = Validator({
'name': 'required',
'email': ['required', 'email'],
'gender': {'one_of': ['male', 'female']},
'age': ['required', 'positiveInteger', {'minNumber': 18}],
'password': ['required', {'min_length': 10}],
'password2': {'equal_to_field': 'password'},
}, SignUp.new);
switch (signUpValidator.validate(input)) {
case ValidationSuccess(:final output):
save(output.email, output.age); // String, int
case ValidationFailure(:final errors):
respond422(errors); // {'email': 'WRONG_EMAIL', 'age': 'TOO_LOW'}
}
Validator(schema, convert)compiles the schema once. An invalid schema (an unknown rule, an invalid regular expression) throwsLivrSchemaExceptionright away. A validator is immutable: create it once and validate any number of inputs.- The type of the output comes from
convert:SignUp.newof an extension type, orUser.fromJsonof a model class (json_serializable,freezed). validate()never changes its input. It accepts anyMap, for example the output ofjsonDecode(); data that is not a map is aValidationFailure('FORMAT_ERROR').validateOrThrow()returns the output or throwsLivrValidationException.- Rule names work as in the specification (
min_length) and in camelCase (minLength). Field names are data and are never converted. autoTrim: true(orValidator.defaultAutoTrim = true) trims all strings of the input before validation.
Types of the output
Nothing checks that the type matches the schema, so write the types the rules return:
| Rules | Type |
|---|---|
integer, positive_integer |
int |
decimal, positive_decimal |
double |
min_number, max_number, number_between |
num |
string rules, email, url, iso_date, modifiers |
String |
nested_object, variable_object |
Map<String, Object?>, or an extension type over it |
list_of, list_of_objects, list_of_different_objects |
List<Object?> |
A field without required or default can be missing, so its type is nullable. A mismatch is a TypeError when the field is read, or in fromJson.
Schemas without a type
When the schema is not known at compile time (loaded from a database, different for every customer) or a service only checks data, Validator.untyped() returns the output as a Map<String, Object?>:
final validator = Validator.untyped(rulesFromDatabase);
result.map(User.fromJson) converts the output of one result.
Custom rules
Everything is a rule: the built-in rules in lib/src/rules/ are written exactly like your own, one rule per file, each with its own error codes. A rule builder gets the arguments of the rule from the schema and returns the rule. The rule returns a RuleResult: RuleResult.valid, a new value with RuleResult.value(v), or an error with RuleResult.error('CODE'). The compiler checks that every path returns one.
import 'package:livr/livr.dart';
import 'package:livr/util.dart';
LivrRule strongPassword(List<Object?> args, RuleContext context) {
final minLength = numberArg(args, 0, 'strong_password'); // {strong_password: 8}
return (value, allValues) {
if (isNoValue(value)) return RuleResult.valid; // leave empty values to 'required'
if (value is! String) return RuleResult.error('FORMAT_ERROR');
if (value.length < minLength) return RuleResult.error('WEAK_PASSWORD');
return RuleResult.valid;
};
}
extension type Account(Map<String, Object?> _) {
String get password => _['password'] as String;
}
final accountValidator = Validator(
{'password': ['required', {'strong_password': 8}]},
Account.new,
rules: {'strong_password': strongPassword}, // also found as strongPassword
);
Validator.registerDefaultRules({...})registers rules for all validators created afterwards.- Do the expensive work in the builder (parse arguments, compile regular expressions), so the rule only checks values.
allValuesis the object the value belongs to, for rules that compare fields.package:livr/util.darthas the helpers the built-in rules use:isNoValue,isPrimitive,livrToString,parseNumber, argument readers.- Metarules compile nested rules through the
RuleContext:context.compileField(rules)andcontext.compileObject(schema)return rules that validate a value or an object. All built-in metarules are written this way.
Aliases
An alias names a set of rules, with an optional error code, without code:
extension type Person(Map<String, Object?> _) {
int get age => _['age'] as int;
}
final personValidator = Validator(
{'age': ['required', 'adult_age']},
Person.new,
aliases: [
const LivrAlias(
name: 'adult_age',
rules: ['positive_integer', {'min_number': 18}],
error: 'WRONG_AGE',
),
],
);
LivrAlias.fromJson() reads the JSON form, and Validator.registerAliasedDefaultRule() registers an alias for all validators.
Rules
| Group | Rules |
|---|---|
| Common | required, not_empty, not_empty_list, any_object |
| String | string, eq, one_of, min_length, max_length, length_equal, length_between, like |
| Numeric | integer, positive_integer, decimal, positive_decimal, min_number, max_number, number_between |
| Special | email, url, iso_date, equal_to_field |
| Modifiers | trim, to_lc, to_uc, remove, leave_only, default |
| Metarules | nested_object, variable_object, list_of, list_of_objects, list_of_different_objects, or |
See livr-spec.org for every rule and its error codes.
Dart specifics
The specification leaves some details to the language. This implementation chooses:
- Output types follow the rule, see Types of the output.
- Numbers as strings are formatted like in JavaScript, so values compare the same way in all implementations:
10.0is"10". - Booleans are not numbers: numeric rules return their error for
true. - Strings of numbers are decimal numbers only, so no hexadecimal,
InfinityorNaN. - String lengths are counted in characters (Unicode code points): an emoji is one character.
- Empty values: a missing field,
nulland''are all empty. A field withnullstays in the output asnull; a missing field stays missing. likeuses Dart regular expressions, which have the syntax of JavaScript ones. Only theiflag is supported, as in the specification.
Performance
Schemas are compiled into closures once, never into source code. Validation is a loop over the compiled rules that allocates little: the output map is laid out by the schema, so it is filled by index without hashing.
The scenarios of the LIVR benchmarks (a five-field form; an object with two levels of nesting and a list), with a validator created once (static) and a new validator per validation (dynamic), on one core:
| Scenario | Dart AOT | Dart JIT |
|---|---|---|
| Simple, static | 5.6 M ops/sec | 7.0 M ops/sec |
| Simple, dynamic | 2.0 M ops/sec | 2.6 M ops/sec |
| Nested, static | 3.3 M ops/sec | 3.2 M ops/sec |
| Nested, dynamic | 1.2 M ops/sec | 1.3 M ops/sec |
On the same machine, the JavaScript implementation (Node.js 26) does 4.4, 1.9, 2.5 and 0.95 M ops/sec. Run the benchmark yourself:
dart run benchmark/benchmark.dart
dart compile exe benchmark/benchmark.dart -o build/benchmark && build/benchmark
Development
dart test # unit tests and the LIVR test suite
dart test -p node # the tests on the web (dart2js), needs Node.js
dart analyze --fatal-infos && dart format --set-exit-if-changed .
test/test_suite is a copy of the LIVR test suite; the page has the command that replaces it with the current one.
Libraries
- livr
- Validation with LIVR (Language Independent Validation Rules): rules are plain data, see https://livr-spec.org.
- rules
- The builder functions of all built-in rules, to compose rules of your own.
- util
- Helpers for custom rules: the checks and conversions the built-in rules use.