ZTO Banner

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 infers values from the enum; pass values: [...] explicitly for a plain String field.
  • @ZList takes a ZtoField type in itemType (e.g. ZString, ZInt) β€” for a list of nested DTOs use @ZListOf instead.
  • @ZListOf / @ZObj take either dtoSchema: (the generated $Schema) or dtoType: (the DTO class, looked up from the registry). For a single nested field, prefer @ZObject β€” it infers the type from the declaration, so no dtoType/dtoSchema is 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 (or e.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.errorFormatter to build a client-facing payload from issues β€” keep that formatter for your internal logging only.

The defaults (ZtoException.toMap() / _defaultFormat) include the full errors list precisely so you can pipe it to logging. Make the conscious choice to strip it before responding to the client.

License

MIT

Libraries

zto
Annotation-based DTO validation and OpenAPI schema generation for Dart.