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.
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 theAckSchema
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
andgetErrors()
.
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);
}