Validation
dox_validation checks input and gives you typed Dart objects.
It has two parts:
- DTOs: a class with
@Dto()and rule annotations. A generator writes the checks. Use this for almost everything. - Fluent schemas:
v.object(...), like Zod. Use it only when the shape is known at runtime.
Both give all errors at once, and a JSON Schema for OpenAPI.
In a Dox app
dox_core uses dox_validation and exports it. There is nothing to install: import 'package:dox_core/dox_core.dart' is all you need.
- Make a DTO with
dox make:dto CreateUser. It writeslib/app/dtos/create_user/create_user.dto.dart. - Run the generator:
dox build_runner:build. - Give the list of DTOs to the app, once:
import 'package:my_app/generated/dtos.g.dart';
AppConfig appConfig = AppConfig(
// ...
dtos: dtos, // from lib/generated/dtos.g.dart
);
Then ask for the DTO in a controller. Dox checks the body before the method runs. Bad input gets 422 with the errors:
class UserController {
Future<Map<String, dynamic>> store(CreateUser body) async {
// body is already checked and typed
return <String, dynamic>{'name': body.name};
}
}
The full guide is at Validation. Every rule is in the rules reference. The rest of this page is about the package itself.
Without Dox
The DTO generator is in dox_builder. Run it with dart run build_runner build.
@Unique and @Exists are in dox_orm, not here.
DTOs
import 'package:dox_validation/dox_validation.dart';
part 'create_user.dto.g.dart';
@Dto()
class CreateUser {
@Length(min: 3, max: 50)
@Trim()
final String name;
@Email()
final String email;
const CreateUser({required this.name, required this.email});
static const DtoSchema<CreateUser> schema = $CreateUserDto;
}
The generator makes, for each @Dto class:
| Name | What it is |
|---|---|
$CreateUserDto |
a const DtoSchema<CreateUser>: the checks, the converter and the JSON Schema |
lib/generated/dtos.g.dart |
const List<DtoSchema<Object>> dtos: every DTO in lib/. Dox uses it to find DTOs by type. An app with no DTO gets an empty final list. |
The static const ... schema = $CreateUserDto; line is how you reach the schema: CreateUser.schema.
DtoSchema<T>
DtoSchema<T> is also a Schema<T>. So it works everywhere a schema works:
inside v.object(...), v.list(...), with OpenAPI, and as a StandardSchema.
| Member | Does |
|---|---|
Future<T> validateMap(Map<String, dynamic> input, {Source source = Source.body, Map<String, String> params = const {}, MessageResolver? messages, String? locale}) |
all checks, then the object. Throws ValidationException with the errors. |
Future<ParseResult<T>> safeValidateMap(input, {source, params, messages, locale}) |
the same, but never throws for bad input. result.success, result.value, result.errors. |
T fromMap(Map<String, dynamic> input) |
builds the object with no checks. Reads keys, converts JSON types, uses defaults. A wrong type throws a TypeError. Only for data you trust. |
Map<String, dynamic> toMap(T value) |
the object as a map with the input keys (snake_case). DateTime becomes an ISO 8601 string, an enum its name. |
Map<String, dynamic> toJsonSchema() |
JSON Schema (draft 2020-12). |
parse, safeParse, parseAsync, safeParseAsync |
the normal Schema methods, with Source.body. parse throws a StateError when the DTO has async rules. |
Type get type, String get name |
CreateUser and 'CreateUser'. |
source: Source.query reads the map like a query string: text is converted to the field type.
params are path params, used by @Unique(ignoreParam: ...).
messages and locale change the error text for this call. See Messages.
ParseResult<CreateUser> result = await CreateUser.schema.safeValidateMap(input);
if (!result.success) {
print(result.errors); // {'email': 'The email is not a valid email'}
}
ParseResult member |
What it is |
|---|---|
success |
true when there are no errors |
value |
the result. null when success is false |
errors |
the first message for each field, like {'user.email': '...'} |
fieldErrors |
all messages for each field |
issues |
the full list of ValidationIssue objects, with code and path |
Note
dox_core has its own ValidationException (the HTTP 422 one), so it does not export the one from dox_validation.
In a Dox app, both answer 422 when thrown in a controller. In tests, prefer safeValidateMap.
JSON Schema of a DTO
For the CreateUser from the Validation guide (with age and role):
{
"type": "object",
"title": "CreateUser",
"properties": {
"name": { "type": "string", "minLength": 3, "maxLength": 50 },
"email": { "type": "string", "format": "email" },
"age": { "type": ["integer", "null"], "minimum": 18 },
"role": { "type": "string", "enum": ["admin", "user"], "default": "user" }
},
"required": ["name", "email"]
}
requiredlists the required fields only.- A nullable field has
"type": [..., "null"]. A default becomesdefault. - A nested DTO goes in
$defsunder its class name, with a$ref. OpenAPI moves it tocomponents/schemas. @Dto(strict: true)adds"additionalProperties": false.- A
///doc comment on a field becomesdescription.@Describe('...')on a field sets it too, and wins over the comment. It does not change the checks. - Each rule adds its keys. See the rules reference.
Fluent schemas (for dynamic input)
Use these when you only know the fields at runtime. The output is a Map, not a class.
final ObjectSchema createUser = v.object(<String, Schema<Object?>>{
'name': v.string().trim().min(3).max(50),
'email': v.string().email(),
'age': v.coerce.int().min(18).optional(),
'role': v.enumOf<String>(<String>['admin', 'user']).defaultTo('user'),
'tags': v.list(v.string()).max(5),
'address': Address.schema, // a DTO inside a fluent schema
});
// throws ValidationException when the input is not valid
Map<String, dynamic> data = createUser.parse(input);
// never throws for bad input
ParseResult<Map<String, dynamic>> result = createUser.safeParse(input);
In a controller: Map<String, dynamic> data = await req.validateSchema(createUser);
Different defaults
Fluent schemas do not follow the DTO rules. A key is required unless you add optional().
"" is a value, not "missing". Text is not converted, unless you use v.coerce.
Schemas
| Schema | Checks |
|---|---|
v.string() |
min, max, length, nonEmpty, email, url, uuid, ip, datetime, regex, startsWith, endsWith, includes, alpha, alphaDash, alphaNumeric, json, digits, digitsBetween, dateFormat, password, exists, unique, trim, toLowerCase, toUpperCase |
v.int(), v.num() |
min, max, between, gt, lt, positive, nonNegative, multipleOf, exists, unique |
v.bool() |
|
v.date() |
DateTime only. min, max |
v.enumOf([...]), v.literal(x) |
|
v.list(item) |
min, max, nonEmpty |
v.object({...}) |
strict, passthrough, strip, pick, omit, extend, partial |
v.map(values, keys: ...) |
any keys |
v.union([...]) |
the first match wins |
v.any(), v.file() |
file() has maxSize, minSize, mimes, mimeTypes, dimensions, image and extensions |
On every schema:
optional(): the key may be missing.nullis still an error. After it:requiredIf(other, value)andrequiredWith([others]).nullable(): the value may benull. The key must be there.nullish(): both.defaultTo(x): the value when the key is missing. (defaultis a reserved word in Dart.)transform(fn),refine(fn),refineAsync(fn),superRefine(fn),superRefineAsync(fn),use(rule).same(other),confirmed(): compare with another key of the same object.describe(text),example(x): only used in the JSON Schema.
Coercion
Query strings and form data are text. v.coerce turns text into the right type first.
v.coerce.int().parse('42'); // 42
v.coerce.num().parse('4.5'); // 4.5
v.coerce.bool().parse('on'); // true (true/1/yes/on, false/0/no/off)
v.coerce.date().parse('2024-01-31');
v.coerce.string().parse(5); // '5'
Checks across fields
final ObjectSchema signUp = v.object(<String, Schema<Object?>>{
'password': v.string().min(8),
'confirm': v.string(),
}).refine((Map<String, dynamic> data) {
return data['password'] == data['confirm'];
}, message: 'Passwords do not match', path: <Object>['confirm']);
More rules
final ObjectSchema signUp = v.object(<String, Schema<Object?>>{
// needs password_confirmation with the same value
'password': v.string().password(min: 8, mixedCase: true, numbers: true).confirmed(),
'email': v.string().email().unique('users', column: 'email'),
'team_id': v.int().exists('teams'),
'type': v.enumOf<String>(<String>['person', 'company']),
'company': v.string().optional().requiredIf('type', 'company'),
'phone': v.string().digitsBetween(8, 12).optional(),
'country_code': v.string().optional().requiredWith(<String>['phone']),
'birthday': v.string().dateFormat('Y-m-d').optional(),
'settings': v.string().json().optional(),
'age': v.int().between(18, 120).optional(),
});
Map<String, dynamic> data = await signUp.parseAsync(input);
They use the same codes and messages as the DTO annotations. See the rules reference.
requiredIfandrequiredWithcome afteroptional()ornullish(). Then a missing,nullor blank value is an error when the condition is true.existsanduniqueare async. UseparseAsync()orsafeParseAsync().- For one value, the database decides what matches. On MySQL
TAKEN@dox.devis taken whentaken@dox.devis there. exists(table, column: 'id'): the column default isid. For a number id column, usev.int().exists('teams'), notv.string(). Then text likeabcis a type error, before any query.unique(table, column: 'email', ignore: id):columnis required. It is never taken from the input key, because a key inv.map()comes from the user.ignoreskips the row whereignoreColumn(defaultid) has this value. Use it for updates.
The presence verifier
exists and unique ask a PresenceVerifier (like in Laravel). dox_orm sets one when the database starts, so in a Dox app they just work.
Outside Dox, or with another database layer, set your own. Each isolate has its own value, so set it in a provider's setup(), which runs in every isolate:
class MyVerifier extends PresenceVerifier {
@override
Future<Set<String>> found(
String table,
String column,
List<Object?> values, {
String? exceptColumn,
Object? exceptValue,
String? connection,
}) async {
// Return the values that are in the column, as text.
// Bind the values as query parameters, never put them into the SQL.
return <String>{};
}
}
class VerifierProvider extends DoxProvider {
@override
void setup() {
PresenceVerifier.instance = MyVerifier();
}
}
Table and column names are checked when the schema is built. They take the same names as the dox_orm query builder: letters, digits, _ and $, with . between parts, like 2fa_codes or public.users. Other names throw an ArgumentError.
Async checks
refineAsync runs a check that needs the database. Then use parseAsync() or safeParseAsync().
parse() throws a StateError when a schema has async checks.
Custom rules in a fluent schema
A Rule<T> class works in both worlds: as an annotation on a DTO, and with use() here.
See Custom rules for how to write one.
Messages
Each error has a code, like required or min_length.
Placeholders: {field}, {value}, and rule values like {min} and {max}.
// for the whole app (outside Dox; in Dox use AppConfig(validationMessages: ...))
MessageResolver.global = MessageResolver(messages: <String, String>{
'required': 'Please fill in {field}',
});
// for one call
createUser.safeParse(input, messages: MessageResolver(messages: <String, String>{
'email.required': 'We need your email',
}));
A resolver for one call is used in place of the global one. Codes that it does not have get the built-in text, not the global text.
For other languages, extend MessageResolver and override resolve().
See Messages.
Standard Schema
Every schema, and every DtoSchema, implements the Standard Schema interface (StandardSchema).
Code that only needs to validate can accept any StandardSchema, from Dox or from another library.