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 and serialization.
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 models and schemas instead of raw maps.
- IDE Support: Leverage auto-complete, refactoring, and compile-time checks.
- Declarative Validation: Define validation rules directly on your model classes using annotations.
- Automatic Serialization: Easily convert between JSON/maps, your models, and generated schema objects.
- Reduced Boilerplate: Eliminates the need to manually write schema definitions and validation logic for models.
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: ^1.0.0 # Replace with latest version
dev_dependencies:
# Ack code generator
ack_generator: ^1.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_generator/ack_generator.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 {
// Property Annotations define validation rules
@IsEmail()
final String email;
@MinLength(3)
@MaxLength(50)
final String name;
@Min(13) // Must be 13 or older
final int? age; // Nullable type, validation applies if value is present
// Field to hold additional properties (must match additionalPropertiesField)
final Map<String, dynamic> metadata;
// Standard Dart constructor
User({
required this.email,
required this.name,
this.age,
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, parsing, and serialization.
Schema Structure
The generated schema class typically includes:
- A static
schema
getter (UserSchema.schema
) representing theAckSchema
instance. - A static
fromModel()
method (UserSchema.fromModel(userInstance)
) to create a schema instance from your model instance. - An instance method
toModel()
to convert the schema instance back to your model class. - An instance method
toMap()
(ortoJson()
) for serialization. - Automatic validation in the constructor.
Validation
Validation happens automatically when you create an instance of the generated schema class. You can then check if the data is valid using the isValid
property.
// 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) {
print('User data is valid!');
} else {
print('Validation failed: ${userSchema.getErrors()}'); // Should not happen here
}
if (!invalidUserSchema.isValid) {
print('Invalid data validation failed as expected:');
// See Error Handling guide for details on SchemaError
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.
Converting Schema Instance to Model
Once you have an instance of the generated schema class, use the toModel()
method to get an instance of your original model class (User
). Make sure to check isValid
first to avoid exceptions.
// Create a schema instance - validation happens automatically
final userSchema = UserSchema(userData);
// Check if valid before converting to model
if (userSchema.isValid) {
final userModel = userSchema.toModel();
// Now you have a fully typed User model instance
print('User Model: Name=${userModel.name}, Email=${userModel.email}');
// Access additional properties stored in the metadata field
print('User Metadata Role: ${userModel.metadata['role']}');
}
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": 2
},
"email": {
"type": "string",
"format": "email"
},
"age": {
"type": "integer",
"minimum": 0,
"nullable": true
}
}
}
*/
This makes it easy to generate API documentation or validate requests and responses against your schema.
Converting Model to Schema Instance
To go the other way (from your model instance to a schema instance), use the static fromModel
method.
// Create an instance of your model
final userModelInstance = User(
email: 'jane@example.com',
name: 'Jane Doe',
age: 28,
metadata: {'status': 'active'},
);
// Convert the model instance to a schema instance
final userSchemaInstance = UserSchema.fromModel(userModelInstance);
print('Converted model to schema instance.');
Serialization (Schema to Map/JSON)
The generated schema instance provides a toMap()
method (which can be easily converted to JSON) for serialization.
// Assuming userSchemaInstance is a valid UserSchema instance
final mapRepresentation = userSchemaInstance.toMap();
// Convert map to JSON string (requires dart:convert)
import 'dart:convert';
final jsonString = jsonEncode(mapRepresentation);
print('JSON output: $jsonString');
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()
: Makes a nullable property mandatory.@Nullable()
: Explicitly marks a property as nullable (often redundant if the Dart type is already nullable, but can improve clarity).@Description('...')
: Adds a description to the property in generated schemas/docs.
String:
@IsEmail()
@IsUrl()
@IsDate()
@IsDateTime()
@MinLength(int)
@MaxLength(int)
@Length(int)
@Matches(String)
@Contains(String)
@IsNotEmpty()
@EnumValues(List<String>)
Number (Int/Double):
@Min(num)
@Max(num)
@Positive()
@Negative()
@MultipleOf(num)
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_generator/ack_generator.dart';
@Schema()
class Address {
@Required() // Example: street is required even if type is String?
final String? street;
final String city;
Address({this.street, required this.city});
}
// file: user.dart (updated)
import 'package:ack_generator/ack_generator.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});
}
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 and convert to model if valid
final userSchema = UserSchema(userData);
if (userSchema.isValid) {
final userModel = userSchema.toModel();
// Access the primary address
Address primaryAddress = userModel.address;
print(primaryAddress.city); // Outputs: Anytown
// Access the alternate addresses
List<Address>? otherAddresses = userModel.alternateAddresses;
if (otherAddresses != null) {
for (final addr in otherAddresses) {
print(addr.street); // Outputs: 456 Oak Ave
}
}
}