zto 0.1.3
zto: ^0.1.3 copied to clipboard
Annotation-based DTO validation and OpenAPI schema generation for Dart server-side.
ZTO - Zero Friction DTO Validation #
Simple and powerful DTO validation for Dart backend applications. Write your DTOs once, get validation and OpenAPI documentation for free.
π Read skill β agent skill with the conventions, annotations, and production patterns for working with
zto.
Features #
- Simple DTOs β Write DTOs with annotations for validation
- Automatic Validation β Type checking + custom validators run at parse time
- Schema Generation β Auto-generated schemas with
zto_generator - OpenAPI Ready β Convert DTOs to OpenAPI 3.0 specs for Swagger/OpenAPI docs
- Business Rules β Chain
.refine()for complex validations - Type Safe β Full type safety with Dart generics
Ecosystem #
zto is the core of a three-package toolchain. You write annotated DTOs once and
the companion packages do the rest:
| Package | Role |
|---|---|
zto |
This package. Annotations (@ZDto, @ZString, validatorsβ¦) + the runtime Zto.parse() / ZtoSchema validation engine. |
zto_generator |
build_runner code generator. Reads your annotated classes and emits the ZtoSchema constants (e.g. $CreateUserDtoSchema) used at runtime. Run it with dart run build_runner build. |
dart_frog_open_api |
OpenAPI/Swagger spec generator for Dart Frog. Consumes your ZtoSchemas to document routes β see OpenAPI Integration. |
@ZDto annotated class
β zto_generator (build_runner)
βΌ
$CreateUserDtoSchema (ZtoSchema)
β β
β zto β dart_frog_open_api
βΌ βΌ
Zto.parse() validation OpenAPI / Swagger docs
Quick Start (3 Steps) #
Step 1: Define Your DTO #
Create a file lib/domain/dtos/user_dto.dart:
import 'package:zto/zto.dart';
part 'user_dto.g.dart'; // Generated validation code
@ZDto(description: 'Request to create a user')
class CreateUserDto {
@ZString(description: 'User full name', example: 'John Doe')
@ZMinLength(2)
@ZMaxLength(100)
final String name;
@ZString(description: 'User email address', example: 'john@example.com')
@ZEmail()
final String email;
@ZInt(description: 'User age', example: 25)
@ZMin(18)
@ZMax(120)
final int age;
@ZString(description: 'Optional phone number')
final String? phone; // Nullability from `?` suffix
const CreateUserDto({
required this.name,
required this.email,
required this.age,
this.phone,
});
factory CreateUserDto.fromMap(Map<String, dynamic> map) {
return CreateUserDto(
name: map['name'] as String,
email: map['email'] as String,
age: map['age'] as int,
phone: map['phone'] as String?,
);
}
}
Step 2: Generate Schemas #
Run the code generator:
dart run build_runner build
This creates user_dto.g.dart with the schema constant $CreateUserDtoSchema.
Step 3: Validate in Your Route #
// In your route handler:
final body = await request.json() as Map<String, dynamic>;
try {
final dto = $CreateUserDtoSchema.parse(body, CreateUserDto.fromMap).refine(
(d) => d.age < 150,
field: 'age',
message: 'Age is unrealistic',
);
// dto is now validated and safe to use
print('User: ${dto.name}, Email: ${dto.email}');
} on ZtoException catch (e) {
// Handle validation errors
return Response.json(
statusCode: 422,
body: {
'errors': e.issues
.map((issue) => {
'field': issue.field,
'message': issue.message,
})
.toList(),
},
);
}
Done! You now have:
- β Type validation (string, int, email, etc.)
- β Custom validators (min length, email format, etc.)
- β Null safety
- β Clear error messages
Class Annotations: @ZDto, @ZEntity, @ZModel #
A class becomes a Zto schema by annotating it with one of three semantically
equivalent annotations. All three generate the exact same ZtoSchema β the
only difference is intent, so the annotation documents which layer the type
belongs to:
| Annotation | Use for | Typical layer |
|---|---|---|
@ZDto |
Data transfer objects β transport/contract shapes (request & response bodies) | API boundary |
@ZEntity |
Domain entities with business rules | Domain |
@ZModel |
Persistable aggregates (not necessarily a database table) | Persistence |
They share the same parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
description |
String |
required | Shown in OpenAPI / Swagger UI |
parseType |
ParseType |
ParseType.camelCase |
How field names map to JSON keys (see below) |
deprecated |
bool |
false |
Marks the schema as deprecated in OpenAPI |
@ZDto(description: 'Create user request')
class CreateUserDto { ... }
@ZEntity(description: 'User domain entity')
class UserEntity { ... }
@ZModel(description: 'Persistable user aggregate')
class UserModel { ... }
Because the three are equivalent, a field whose type is annotated with any of them is auto-detected as a nested object β no
@ZObject()needed.
ParseType β Field Name Mapping #
parseType controls how Dart field names are converted to JSON map keys when a
field doesn't set an explicit mapKey. Declare it once on the class annotation
and it applies to every field of that class:
ParseType |
Transformation | firstName becomes |
|---|---|---|
camelCase (default) |
none β used as-is | firstName |
snakeCase |
camelCase β snake_case | first_name |
pascalCase |
uppercases the first letter | FirstName |
kebabCase |
camelCase β kebab-case | first-name |
@ZDto(description: 'User', parseType: ParseType.snakeCase)
class UserDto {
@ZString()
final String firstName; // JSON key: first_name
@ZString()
final String lastName; // JSON key: last_name
}
An explicit mapKey always wins over parseType inference:
@ZDto(description: 'User', parseType: ParseType.snakeCase)
class UserDto {
@ZString(mapKey: 'email_address') // overrides snake_case inference
final String email; // JSON key: email_address
}
Field Types #
Annotate one type per field. Every type annotation shares these params: mapKey
(explicit JSON key), description and example (shown in OpenAPI/Swagger), failMessage
(custom type-mismatch message), and deprecated.
| Annotation | Dart type | Example |
|---|---|---|
@ZString |
String |
@ZString(description: 'Full name', example: 'Alice') |
@ZInt |
int |
@ZInt(description: 'Age in years', example: 25) |
@ZDouble |
double |
@ZDouble(description: 'Unit price', example: 9.99) |
@ZNum |
num |
@ZNum(description: 'Score', example: 87.5) |
@ZBool |
bool |
@ZBool(description: 'Whether active', example: true) |
@ZDate |
DateTime |
@ZDate(description: 'Created at', example: '2024-03-15T10:00:00Z') |
@ZFile |
upload | @ZFile(description: 'Profile image') |
@ZEnum |
enum / String |
@ZEnum(values: ['admin', 'editor', 'viewer'], description: 'Role') |
@ZMap |
Map<String, dynamic> |
@ZMap(description: 'Raw metadata') |
@ZMetaData |
Map<String, dynamic> |
@ZMetaData(description: 'User metadata', example: {'plan': 'pro'}) |
@ZList |
List<primitive> |
@ZList(itemType: ZString, description: 'List of tags') |
@ZListOf |
List<NestedDto> |
@ZListOf(dtoType: AddressDto, description: 'Addresses') |
@ZObj |
nested DTO (explicit) | @ZObj(dtoType: AddressDto, description: 'Billing address') |
@ZObject |
nested DTO (inferred) | @ZObject(description: 'Address') β type read from the field |
@ZEnumβ on an enum-typed field the generator infersvaluesfrom the enum; passvalues: [...]explicitly for a plainStringfield.@ZListtakes aZtoFieldtype initemType(e.g.ZString,ZInt) β for a list of nested DTOs use@ZListOfinstead.@ZListOf/@ZObjtake eitherdtoSchema:(the generated$Schema) ordtoType:(the DTO class, looked up from the registry). For a single nested field, prefer@ZObjectβ it infers the type from the declaration, so nodtoType/dtoSchemais needed.
Validators #
Stack validators below the type annotation. They run at parse time and collect all
failures (they don't stop at the first). Every validator accepts an optional message: to
override the default error. The build fails with a clear error if a validator is
incompatible with the field type (e.g. @ZEmail on a @ZDouble).
String (under @ZString):
| Validator | Passes β / Fails β |
|---|---|
@ZMinLength(2) |
'abc' β / 'a' β |
@ZMaxLength(10) |
'hello' β / 'hello world!' β |
@ZLength(5) |
'12345' β / '1234' β |
@ZEmail() |
'a@b.com' β / 'invalid' β |
@ZUuid() |
'550e8400-e29b-41d4-a716-446655440000' β / 'x' β |
@ZUrl() |
'https://example.com' β / 'not-a-url' β |
@ZHttpUrl() |
'https://x.com' β / 'ftp://x.com' β |
@ZPattern(r'^[a-z]+$') |
'abc' β / 'Abc' β |
@ZStartsWith('https://') |
'https://x.com' β / 'http://x.com' β |
@ZEndsWith('.com') |
'site.com' β / 'site.org' β |
@ZIncludes('foo') |
'hello foo' β / 'hello bar' β |
@ZBase64() |
'SGVsbG8=' β / '!!!' β |
@ZHex() |
'deadbeef' β / 'ghijk' β |
@ZIpv4() |
'192.168.1.1' β / '256.1.1.1' β |
@ZIpv6() |
'2001:0db8::1' β / 'invalid' β |
@ZJwt() |
'a.b.c' β / 'a.b' β |
@ZIsoDate() |
'2024-03-15' β / '2024-13-01' β |
@ZIsoDateTime() |
'2024-03-15T10:00:00Z' β / '2024-03-15' β |
@ZUppercase() |
'ABC' β / 'Abc' β |
@ZLowercase() |
'abc' β / 'Abc' β |
@ZSlug() |
'my-blog-post' β / 'Invalid Slug!' β |
@ZAlphanumeric() |
'abc123' β / 'abc-123' β |
Numeric (under @ZInt / @ZDouble / @ZNum; @ZMin/@ZMax also work on @ZDate):
| Validator | Passes β / Fails β |
|---|---|
@ZMin(18) |
18, 25 β / 17 β |
@ZMax(120) |
100, 120 β / 121 β |
@ZPositive() |
1 β / 0, -1 β |
@ZNegative() |
-5 β / 5 β |
@ZNonNegative() |
0, 1 β / -1 β |
@ZNonPositive() |
0, -5 β / 1 β |
@ZMultipleOf(5) |
10, 15 β / 12 β |
@ZInteger() |
10 β / 9.99 β |
@ZFinite() |
42 β / infinity, nan β |
@ZSafeInt() |
9007199254740991 β / 9007199254740992 β |
Advanced Usage #
Custom Validation with .refine() #
final dto = $CreateUserDtoSchema.parse(body, CreateUserDto.fromMap)
.refine(
(user) => user.age >= 18,
field: 'age',
message: 'Must be an adult',
)
.refine(
(user) => !user.email.contains('+'),
field: 'email',
message: 'Email aliases not allowed',
);
Parse Multiple Items #
final users = $CreateUserDtoSchema.parseList(
jsonArray,
CreateUserDto.fromMap,
);
Alternate Factories #
Use any factory method you want:
// All work the same way
final dto1 = $CreateUserDtoSchema.parse(data, CreateUserDto.fromMap);
final dto2 = $CreateUserDtoSchema.parse(data, CreateUserDto.fromJson);
final dto3 = $CreateUserDtoSchema.parse(data, CreateUserDto.fromApi);
Error Handling #
try {
final dto = $CreateUserDtoSchema.parse(body, CreateUserDto.fromMap);
} on ZtoException catch (e) {
// e.issues contains all validation errors
for (final issue in e.issues) {
print('Field: ${issue.field}, Message: ${issue.message}');
}
}
Common Patterns #
Create vs Update DTOs #
Use optional fields for update operations:
@ZDto(description: 'Create a new user')
class CreateUserDto {
@ZString()
@ZMinLength(2)
final String name;
@ZString()
@ZEmail()
final String email;
const CreateUserDto({required this.name, required this.email});
}
@ZDto(description: 'Update an existing user')
class UpdateUserDto {
@ZString()
@ZMinLength(2)
final String? name; // Optional
@ZString()
@ZEmail()
final String? email; // Optional
const UpdateUserDto({this.name, this.email});
}
Response DTOs #
@ZDto(description: 'User response')
class UserResponseDto {
@ZString(description: 'Unique user ID')
final String id;
@ZString(description: 'User name')
final String name;
@ZString(description: 'User email')
final String email;
@ZDate(description: 'Account creation date')
final DateTime createdAt;
const UserResponseDto({
required this.id,
required this.name,
required this.email,
required this.createdAt,
});
}
Ergonomic Features #
Nullability from Dart's ? Suffix #
Nullability is inferred directly from the Dart ? type suffix β there is no
annotation for it:
// This is nullable (optional)
@ZString()
final String? nickname;
// This is required
@ZString()
final String name;
Enum Values Auto-Detection #
If you don't specify values, they're read from the enum:
enum Color { red, green, blue }
@ZEnum() // Automatically becomes: values: ['red', 'green', 'blue']
final Color color;
Nested DTO Auto-Detection #
Fields whose type is annotated with @ZDto, @ZEntity, or @ZModel are automatically treated as objects:
@ZObject() // Not needed anymore
final Address address;
// Just use:
final Address address; // Type is @ZDto, so automatically an object
Testing #
test('validates user creation', () {
final schema = $CreateUserDtoSchema;
// Valid data passes
final validUser = schema.parse(
{'name': 'John', 'email': 'john@example.com', 'age': 25},
CreateUserDto.fromMap,
);
expect(validUser.name, 'John');
// Invalid data throws
expect(
() => schema.parse(
{'name': 'J', 'email': 'invalid', 'age': 15}, // Too short, invalid email, too young
CreateUserDto.fromMap,
),
throwsA(isA<ZtoException>()),
);
});
OpenAPI Integration #
Every ZtoSchema can be converted to an OpenAPI 3.0 schema with DtoToOpenApi:
import 'package:zto/zto.dart';
final openApiSchema = DtoToOpenApi.convert($CreateUserDtoSchema);
// Use in your OpenAPI spec builder
With Dart Frog β dart_frog_open_api #
For Dart Frog apps, the
dart_frog_open_api package does this end-to-end: pass a
ZtoSchema straight into its fluent route builder and it generates the full
OpenAPI/Swagger document (request/response bodies, nested DTOs, validators) for
you β no manual schema wiring.
import 'package:dart_frog_open_api/dart_frog_open_api.dart';
// Describe a route using the zto schema as the request body contract:
Api.path().post((op) => op
.summary('Create user')
.body($CreateUserDtoSchema) // β your zto schema
.response(201));
Under the hood it calls DtoToOpenApi.convert (and OpenApiSchema.fromZto) on
the same schemas you already validate with, so your docs never drift from your
validation rules.
Complete Example #
File: lib/dtos/user_dto.dart
import 'package:zto/zto.dart';
part 'user_dto.g.dart';
@ZDto(description: 'Create a new user')
class CreateUserDto {
@ZString(description: 'Full name', example: 'Alice Smith')
@ZMinLength(2)
final String name;
@ZString(description: 'Email address', example: 'alice@example.com')
@ZEmail()
final String email;
const CreateUserDto({
required this.name,
required this.email,
});
factory CreateUserDto.fromMap(Map<String, dynamic> map) {
return CreateUserDto(
name: map['name'] as String,
email: map['email'] as String,
);
}
}
File: lib/routes/users.dart
import 'package:dart_frog/dart_frog.dart';
import 'package:myapp/dtos/user_dto.dart';
Future<Response> onRequest(RequestContext context) async {
if (context.request.method == HttpMethod.post) {
final body = await context.request.json();
try {
final newUser = $CreateUserDtoSchema.parse(
body as Map<String, dynamic>,
CreateUserDto.fromMap,
);
// Save to database
return Response.json(
statusCode: 201,
body: {'id': '123', 'name': newUser.name, 'email': newUser.email},
);
} on ZtoException catch (e) {
return Response.json(
statusCode: 422,
body: {
'errors': e.issues
.map((i) => {'field': i.field, 'message': i.message})
.toList(),
},
);
}
}
return Response(statusCode: 405);
}
FAQ #
Q: Do I need to write fromMap?
A: Yes, you write the deserialization logic yourself. ZTO only handles validation.
Q: Can I use ZTO with JSON serialization?
A: Yes! Use any factory method (fromMap, fromJson, fromApi, etc.). ZTO validates the same way.
Q: What if my API uses snake_case but Dart uses camelCase?
A: Use @ZDto(parseType: ParseType.snakeCase) on the class to auto-convert.
Q: How do I handle optional fields?
A: Use Dart's ? suffix:
@ZString()
final String? nickname; // Optional field
Q: Can I validate across multiple fields?
A: Yes, use .refine():
final dto = $DtoSchema.parse(data, Dto.fromMap)
.refine((d) => d.password == d.confirmPassword, message: 'Passwords must match');
Performance #
- Code generation: Run once with
dart run build_runner build - Runtime: Validation is O(n) where n = number of fields
- No reflection: Everything is compiled ahead of time
Production Recommendations #
In production, never let validation details reach the client. The issues
inside a ZtoException expose your internal field names, map keys, and business
rules β that's information disclosure that helps an attacker probe your API.
Treat the detailed ZtoException as a backend-only signal: it should flow
exclusively to your logs / analytics, never into the HTTP response body.
Rule of thumb: the client always gets the same generic error message; the full detail goes only to your observability pipeline.
import 'dart:io';
import 'package:dart_frog/dart_frog.dart';
// import your logger (talker) / analytics client
Future<Response> onRequest(RequestContext context) async {
final body = await context.request.json() as Map<String, dynamic>;
try {
final dto = $CreateUserDtoSchema.parse(body, CreateUserDto.fromMap);
// ... use dto
return Response.json(body: {'ok': true});
} on ZtoException catch (e, stack) {
// β
Detailed issues go ONLY to logs / analytics β never to the client.
talker.warning('Validation failed', e, stack);
analytics.track('validation_failed', {
'route': context.request.uri.path,
'issues': e.issues.map((i) => i.toMap()).toList(),
});
// β
Client receives a generic message with no internal detail.
return Response.json(
statusCode: HttpStatus.unprocessableEntity, // 422
body: {'message': 'Invalid request.'},
);
}
}
Do
- Return one fixed, generic message (e.g.
'Invalid request.') for every validation failure. - Forward
e.issues(ore.toMap()) only to logs / analytics on the backend. - Use a single shared error handler / middleware so no route accidentally leaks
issues.
Don't
- Serialize
e.issues/e.toMap()into the response sent to the client. - Echo back field names, regex patterns, or the offending value.
- Configure
Zto.errorFormatterto build a client-facing payload fromissuesβ keep that formatter for your internal logging only.
The defaults (
ZtoException.toMap()/_defaultFormat) include the fullerrorslist precisely so you can pipe it to logging. Make the conscious choice to strip it before responding to the client.
License #
MIT