TypeSafe Schemas
Overview
Ack's schema system bridges the gap between runtime data validation and static type checking. It provides a type-safe approach to validating data against Dart models with automatic validation and strong type safety.
Traditional approaches to data validation often require:
- Separate validation logic from your models
- Manual type casting between dynamic JSON and typed models
- Duplicate schema definitions for documentation and client validation
TypeSafe Schemas solves these problems by combining:
- Strong typing through generated schema classes with typed getters
- Automatic validation when creating schemas from external data
- Schema generation for documentation and cross-platform validation
All from a single model definition with validation annotations.
1. Define Your Model with Annotations
// file: user.dart
import 'package:ack_generator/ack_generator.dart';
// Use the part directive to include the generated code
part 'user.g.dart';
@Schema()
class User {
@MinLength(2)
final String name;
@IsEmail()
final String email;
@Min(18)
final int age;
User({required this.name, required this.email, required this.age});
}
2. Generate the Schema Class
Run the build_runner command to generate the schema class:
dart run build_runner build --delete-conflicting-outputs
This generates a user.g.dart
file with a UserSchema
class that provides validation, type-safety, and schema generation.
1. Type-Safe Schema Access
The generated schema class provides typed access to your data through generated getters:
// Untyped data (e.g., from JSON, form input, API response)
final Map<String, dynamic> data = {
'name': 'John',
'email': 'john@example.com',
'age': 30
};
// Create a schema instance (validates automatically)
final userSchema = UserSchema(data);
if (userSchema.isValid) {
// Access fully typed properties directly through the schema
String name = userSchema.name; // Typed as String
String email = userSchema.email; // Typed as String
int age = userSchema.age; // Typed as int
// Type checking prevents errors like:
// age = "thirty"; // Compile error!
print('Valid user: $name ($email) is $age years old');
}
// Detailed error handling for invalid data
if (!userSchema.isValid) {
final error = userSchema.getErrors();
print('Validation failed: ${error?.message}');
print('Error path: ${error?.path}');
}
2. Model Conversion and Validation
For a more streamlined approach, the generated schema provides methods to directly parse and convert data to your model:
// Option 1: Exception-based parsing (most convenient)
try {
// Parse data directly to a typed model
final User user = UserSchema.parse(data);
// Work with your fully typed model
print('Hello ${user.name}! You are ${user.age} years old');
} catch (e) {
print('Invalid data: $e');
}
// Option 2: Null-safe parsing
final User? user = UserSchema.tryParse(data);
if (user != null) {
// Work with your validated model
print('Hello ${user.name}!');
} else {
print('Validation failed');
}
3. JSON Schema Generation
The same model definition can generate JSON Schema for documentation or client-side validation:
// Generate a JSON Schema definition from your model
final jsonSchema = UserSchema.toJsonSchema();
// Use in API documentation, client validation, etc.
import 'dart:convert';
final jsonString = jsonEncode(jsonSchema);
// Example output (simplified):
// {
// "type": "object",
// "properties": {
// "name": {
// "type": "string",
// "minLength": 2
// },
// "email": {
// "type": "string",
// "format": "email"
// },
// "age": {
// "type": "integer",
// "minimum": 18
// }
// },
// "required": ["name", "email", "age"]
// }
Validating Nested Models
TypeSafe Schemas handle nested models automatically, providing deep validation:
// file: address.dart
import 'package:ack_generator/ack_generator.dart';
part 'address.g.dart';
@Schema()
class Address {
@IsNotEmpty()
final String street;
@IsNotEmpty()
final String city;
Address({required this.street, required this.city});
}
// file: customer.dart
import 'package:ack_generator/ack_generator.dart';
import 'address.dart';
part 'customer.g.dart';
@Schema()
class Customer {
@MinLength(3)
final String name;
// Validates using AddressSchema
final Address address;
// List of addresses, each validated
final List<Address>? secondaryAddresses;
Customer({
required this.name,
required this.address,
this.secondaryAddresses
});
}
Using nested schemas:
final customerData = {
'name': 'John Doe',
'address': {
'street': '123 Main St',
'city': 'Anytown',
},
'secondaryAddresses': [
{
'street': '456 Business Ave',
'city': 'Work City',
}
]
};
// Type-safe access to nested properties
final customerSchema = CustomerSchema(customerData);
if (customerSchema.isValid) {
// Access nested schema properties directly
String name = customerSchema.name;
String street = customerSchema.address.street;
String city = customerSchema.address.city;
// Access nested lists
if (customerSchema.secondaryAddresses != null) {
for (final addr in customerSchema.secondaryAddresses!) {
print('Secondary: ${addr.street}, ${addr.city}');
}
}
}
Working with Additional Properties
You can configure how to handle additional properties not defined in your model:
@Schema(
additionalProperties: true, // Allow additional properties
additionalPropertiesField: 'metadata' // Store them in this field
)
class User {
final String name;
final String email;
// Field to store additional properties
final Map<String, dynamic> metadata;
User({
required this.name,
required this.email,
Map<String, dynamic>? metadata
}) : metadata = metadata ?? {};
}
With this configuration, any properties in the input data that aren't defined in the model will be stored in the metadata
field.
Available Validation Annotations
Ack provides a wide range of validation annotations for your model properties:
String Validation
@MinLength(int)
: String must have at least n characters@MaxLength(int)
: String must have at most n characters@Length(int)
: String must have exactly n characters@IsEmail()
: String must be a valid email address@IsUrl()
: String must be a valid URL@Matches(pattern)
: String must match the regular expression pattern@IsNotEmpty()
: String must not be empty@EnumValues(values)
: String must be one of the specified values
Number Validation
@Min(n)
: Number must be greater than or equal to n@Max(n)
: Number must be less than or equal to n@Positive()
: Number must be positive@Negative()
: Number must be negative@MultipleOf(n)
: Number must be a multiple of n
List Validation
@MinItems(n)
: List must have at least n items@MaxItems(n)
: List must have at most n items@Length(n)
: List must have exactly n items@UniqueItems()
: List items must be unique@IsNotEmpty()
: List must not be empty
Common Validation
@Nullable()
: Field is allowed to be null@Required()
: Field is required (useful for nullable types that must be provided)@DefaultValue(value)
: Default value if the field is not provided@Description(text)
: Description for documentation
Manual Implementation (Advanced)
While code generation is recommended, you can also implement schemas manually:
class UserSchema extends SchemaModel<User> {
UserSchema(Object? data) : super(data);
@override
AckSchema getSchema() {
return Ack.object(
{
'name': Ack.string.minLength(2),
'email': Ack.string.isEmail(),
'age': Ack.int.min(18),
},
required: ['name', 'email', 'age'],
);
}
@override
User toModel() {
if (!isValid) {
throw AckException(getErrors()!);
}
return User(
name: getValue<String>('name')!,
email: getValue<String>('email')!,
age: getValue<int>('age')!,
);
}
// You can add typed getters manually too
String get name => getValue<String>('name')!;
String get email => getValue<String>('email')!;
int get age => getValue<int>('age')!;
}