Code Generation with Ack

This guide explains how to use Ack's code generator (ack_generator) to create schema classes automatically from your Dart models, enabling type-safe validation.

Overview

The ack_generator package integrates with build_runner to generate schema classes from your annotated Dart models. This provides several benefits:

  • Type Safety: Work with strongly-typed schemas and access validated data through typed getters.
  • IDE Support: Leverage auto-complete, refactoring, and compile-time checks.
  • Declarative Validation: Define validation rules directly on your model classes using annotations.
  • Separation of Concerns: Validation is separate from serialization, giving you flexibility.
  • Reduced Boilerplate: Eliminates the need to manually write schema definitions and validation logic.

Setup

1. Add Dependencies

Add ack, ack_generator, and build_runner to your pubspec.yaml (check pub.dev for the latest compatible versions):

dependencies:
  # Core Ack library
  ack: ^2.0.0 # Replace with latest version

dev_dependencies:
  # Ack code generator
  ack_generator: ^2.0.0 # Replace with latest version
  # Standard Dart build tool
  build_runner: ^2.4.0 # Example: Use a recent version

Run dart pub get or flutter pub get.

2. Annotate Your Models

Define your data model as a standard Dart class. Annotate the class with @Schema() and its properties with relevant validation annotations from ack_generator.

// file: user.dart
import 'package:ack/ack.dart';

@Schema(
  description: 'Represents a user account.',
  // Allow properties not explicitly defined in the class
  additionalProperties: true,
  // Store extra properties in the 'metadata' field of the model
  additionalPropertiesField: 'metadata',
)
class User {
  // ✅ Automatic inference with validation annotations
  @IsEmail()                    // Validation rule
  final String email;           // Non-nullable, required from constructor

  @MinLength(3)
  @MaxLength(50)               // Validation rules
  final String name;           // Non-nullable, required from constructor

  @Min(13)                     // Validation rule: Must be 13 or older
  final int? age;              // Nullable type, optional from constructor

  // Field to hold additional properties (must match additionalPropertiesField)
  final Map<String, dynamic> metadata;

  // Constructor parameters automatically determine required/optional status
  User({
    required this.email,       // → Automatically inferred as required
    required this.name,        // → Automatically inferred as required
    this.age,                 // → Automatically inferred as optional (nullable)
    Map<String, dynamic>? metadata,
  }) : metadata = metadata ?? {};
}

3. Generate Schema Classes

Run the build_runner command in your terminal:

# For a one-time build:
dart run build_runner build --delete-conflicting-outputs

# To watch for changes and rebuild automatically:
dart run build_runner watch --delete-conflicting-outputs

This command generates a corresponding .schema.dart file (e.g., user.schema.dart) next to your model file. This generated file contains the UserSchema class.

Important: Do not edit the generated .schema.dart file directly, as your changes will be overwritten.

Using Generated Schemas

The generated {ModelName}Schema class (e.g., UserSchema) provides the primary interface for validation and typed data access.

Schema Structure

The generated schema class includes:

  • A static schema getter (UserSchema.schema) representing the AckSchema instance.
  • Typed getters for each property (e.g., userSchema.name, userSchema.email).
  • An instance method toMap() for extracting the underlying data as a map.
  • Automatic validation in the constructor.
  • Error handling through isValid and getErrors().

Validation and Property Access

Validation happens automatically when you create an instance of the generated schema class. You can then access validated data through typed getters:

// file: main.dart
import 'user.dart';
import 'user.schema.dart'; // Import the generated file

void main() {
  final userData = {
    'email': 'user@example.com',
    'name': 'John Doe',
    'age': 25,
    'role': 'admin' // Additional property captured by metadata
  };

  final invalidUserData = {
    'email': 'invalid-email', // Fails @IsEmail
    'name': 'Jo', // Fails @MinLength(3)
  };

  // Create schema instances - validation happens automatically
  final userSchema = UserSchema(userData);
  final invalidUserSchema = UserSchema(invalidUserData);

  if (userSchema.isValid) {
    // Access typed properties directly
    print('Valid user: ${userSchema.name} (${userSchema.email})');
    print('Age: ${userSchema.age}');
    print('Role: ${userSchema.metadata['role']}');
  }

  if (!invalidUserSchema.isValid) {
    print('Invalid data validation failed as expected:');
    final error = invalidUserSchema.getErrors();
    print('- Error Name: ${error?.name}');
    print('- Error Message: ${error?.message}');
    print('- Error Path: ${error?.path}');
  }
}

Also see the JSON Serialization and TypeSafe Schemas guides.

Creating Model Instances

You have complete control over how to create model instances from validated schemas:

Direct Creation

final userSchema = UserSchema(userData);

if (userSchema.isValid) {
  // Create model directly
  final user = User(
    email: userSchema.email,
    name: userSchema.name,
    age: userSchema.age,
    metadata: userSchema.metadata,
  );
  
  print('Created user: ${user.name}');
}

Factory Method Pattern

// Add to your User class:
class User {
  // ... existing fields ...
  
  factory User.fromSchema(UserSchema schema) {
    if (!schema.isValid) {
      throw AckException(schema.getErrors()!);
    }
    
    return User(
      email: schema.email,
      name: schema.name,
      age: schema.age,
      metadata: schema.metadata,
    );
  }
}

// Usage:
final user = User.fromSchema(UserSchema(userData));

Extension Method Pattern

// Define extension:
extension UserSchemaX on UserSchema {
  User toUser() => User(
    email: email,
    name: name,
    age: age,
    metadata: metadata,
  );
}

// Usage:
if (userSchema.isValid) {
  final user = userSchema.toUser();
}

Generating JSON Schema Definitions

The generated schema classes include methods to convert your schema to JSON Schema format, which is useful for API documentation and validation.

// Get the JSON Schema as a Map
final jsonSchema = UserSchema.toJsonSchema();

// Convert to JSON string if needed
final jsonString = jsonEncode(jsonSchema);
print('JSON Schema:\n$jsonString');

/* Example output:
{
  "type": "object",
  "required": ["name", "email"],
  "properties": {
    "name": {
      "type": "string",
      "minLength": 3,
      "maxLength": 50
    },
    "email": {
      "type": "string",
      "format": "email"
    },
    "age": {
      "type": "integer",
      "minimum": 13,
      "nullable": true
    }
  },
  "additionalProperties": true
}
*/

Integration with Serialization Libraries

Ack works seamlessly with other serialization libraries:

// Example with json_serializable
import 'package:json_annotation/json_annotation.dart';

part 'user.g.dart';  // json_serializable
part 'user.schema.dart';  // ack_generator

@JsonSerializable()
@Schema()
class User {
  @IsEmail()
  final String email;
  
  @MinLength(3)
  final String name;
  
  User({required this.email, required this.name});
  
  // json_serializable methods
  factory User.fromJson(Map<String, dynamic> json) {
    // Validate first with Ack
    final schema = UserSchema(json);
    if (!schema.isValid) {
      throw AckException(schema.getErrors()!);
    }
    // Then deserialize
    return _$UserFromJson(json);
  }
  
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

Available Annotations

Refer to the annotations provided by the ack_generator package. These generally mirror the fluent API methods used for manual schema definition.

Class Level

  • @Schema(description: ..., additionalProperties: ..., additionalPropertiesField: ..., schemaClassName: ...): The main annotation to mark a class for schema generation. See Configuration for parameter details.

Property Level

These annotations correspond to rules defined in the Validation Rules guide.

Common:

  • @Required(): Override annotation - makes an optional constructor parameter required in validation (rarely needed with automatic inference).
  • @Nullable(): Override annotation - makes a non-nullable field accept null values in validation (rarely needed with automatic inference).
  • @Description('...'): Adds a description to the property in generated schemas/docs.

Automatic Inference: The generator automatically determines required/nullable status from your constructor and field types:

  • Use required this.field in constructors for required fields
  • Use String? field types for nullable fields
  • Override annotations are only needed in special cases where you want to override the automatic behavior

String:

  • @IsEmail()
  • @MinLength(int)
  • @MaxLength(int)
  • @Pattern(String) - Regex pattern validation
  • @IsNotEmpty()
  • @EnumValues(List<String>)
  • @IsDate() - Date format validation (YYYY-MM-DD)
  • @IsDateTime() - Date-time format validation (ISO 8601)

Number (Int/Double):

  • @Min(num)
  • @Max(num)
  • @MultipleOf(num)
  • @IsPositive() - Must be greater than 0
  • @IsNegative() - Must be less than 0

List:

  • @MinItems(int)
  • @MaxItems(int)
  • @Length(int)
  • @UniqueItems()
  • @IsNotEmpty()

Note: Ensure the annotations match the property type (e.g., don't use @MinLength on an int).

Working with Nested Models

The code generator automatically handles nested models if the nested type is also annotated with @Schema. This aligns with how nested objects are handled in manual schema definitions.

// file: address.dart
import 'package:ack/ack.dart';

@Schema()
class Address {
  // ✅ Automatic inference example: clean constructor-based approach
  final String street;     // Non-nullable, required in constructor
  final String city;       // Non-nullable, required in constructor
  final String? zipCode;   // Nullable, optional in constructor

  Address({
    required this.street,  // → Automatically inferred as required
    required this.city,    // → Automatically inferred as required
    this.zipCode,         // → Automatically inferred as optional
  });
  
  factory Address.fromSchema(AddressSchema schema) {
    return Address(
      street: schema.street,
      city: schema.city,
      zipCode: schema.zipCode,
    );
  }
}

// file: user.dart (updated)
import 'package:ack/ack.dart';
import 'address.dart';

@Schema()
class User {
  final String name;
  // Reference another @Schema annotated class
  final Address address;
  // List of nested models
  final List<Address>? alternateAddresses;

  User({required this.name, required this.address, this.alternateAddresses});
  
  factory User.fromSchema(UserSchema schema) {
    return User(
      name: schema.name,
      address: Address.fromSchema(schema.address),
      alternateAddresses: schema.alternateAddresses
          ?.map((addrSchema) => Address.fromSchema(addrSchema))
          .toList(),
    );
  }
}

When you run build_runner, it generates address.schema.dart and user.schema.dart. The generated UserSchema will correctly reference AddressSchema for validation of the nested address and alternateAddresses fields.

Accessing Nested Models:

// Create a schema instance with nested data
final userData = {
  'name': 'John Doe',
  'address': {
    'street': '123 Main St',
    'city': 'Anytown',
  },
  'alternateAddresses': [
    {
      'street': '456 Oak Ave',
      'city': 'Othertown',
    },
  ],
};

// Create schema instance
final userSchema = UserSchema(userData);
if (userSchema.isValid) {
  // Access nested schema properties directly
  print('Primary address: ${userSchema.address.street}, ${userSchema.address.city}');
  
  // Access list of nested schemas
  if (userSchema.alternateAddresses != null) {
    for (final addrSchema in userSchema.alternateAddresses!) {
      print('Alternate: ${addrSchema.street}, ${addrSchema.city}');
    }
  }
  
  // Create model when needed
  final user = User.fromSchema(userSchema);
}