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/ack.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 using parse method
final userSchema = UserSchema().parse(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');
}
2. Model Creation Patterns
You have complete control over how to create model instances from validated data:
Direct Property Access
final userSchema = UserSchema().parse(data);
if (userSchema.isValid) {
// Work directly with schema properties
print('Hello ${userSchema.name}! You are ${userSchema.age} years old');
// Create model when needed
final user = User(
name: userSchema.name,
email: userSchema.email,
age: userSchema.age,
);
}
Factory Method Pattern
// Add to your model class
class User {
// ... existing code ...
factory User.fromSchema(UserSchema schema) {
if (!schema.isValid) {
throw AckException(schema.getErrors()!);
}
return User(
name: schema.name,
email: schema.email,
age: schema.age,
);
}
}
// Usage
final user = User.fromSchema(UserSchema().parse(data));
Extension Method Pattern
extension UserSchemaX on UserSchema {
User toUser() => User(name: name, email: email, age: age);
}
// Usage
if (userSchema.isValid) {
final user = userSchema.toUser();
}
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/ack.dart';
part 'address.g.dart';
@Schema()
class Address {
@IsNotEmpty()
final String street;
@IsNotEmpty()
final String city;
Address({required this.street, required this.city});
factory Address.fromSchema(AddressSchema schema) {
return Address(street: schema.street, city: schema.city);
}
}
// file: customer.dart
import 'package:ack/ack.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
});
factory Customer.fromSchema(CustomerSchema schema) {
return Customer(
name: schema.name,
address: Address.fromSchema(schema.address),
secondaryAddresses: schema.secondaryAddresses
?.map((addr) => Address.fromSchema(addr))
.toList(),
);
}
}
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().parse(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}');
}
}
// Create model when needed
final customer = Customer.fromSchema(customerSchema);
}
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@IsEmail()
: String must be a valid email address@Pattern(pattern)
: String must match the regular expression pattern@IsNotEmpty()
: String must not be empty@EnumValues(values)
: String must be one of the specified values@IsDate()
: String must be a valid date in YYYY-MM-DD format@IsDateTime()
: String must be a valid date-time in ISO 8601 format
Number Validation
@Min(n)
: Number must be greater than or equal to n@Max(n)
: Number must be less than or equal to n@MultipleOf(n)
: Number must be a multiple of n@IsPositive()
: Number must be greater than 0@IsNegative()
: Number must be less than 0
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()
: Override annotation - makes a non-nullable field accept null values in validation@Required()
: Override annotation - makes an optional constructor parameter required in validation@DefaultValue(value)
: Default value if the field is not provided@Description(text)
: Description for documentation
Automatic Inference: With Ack's enhanced automatic inference, @Required()
and @Nullable()
annotations are rarely needed. The generator automatically determines:
- Required status from constructor parameters:
required this.field
→ field is required - Nullable status from field types:
String?
→ field is nullable
Use override annotations only when you need to override the automatic inference behavior.
Automatic Inference Examples
@Schema()
class User {
// ✅ Automatic inference - no annotations needed
final String name; // Non-nullable field
final String? email; // Nullable field
final int age; // Non-nullable field
User({
required this.name, // → Automatically inferred as required
this.email, // → Automatically inferred as optional
required this.age, // → Automatically inferred as required
});
}
// Generated schema automatically creates:
// required: ['name', 'age'] // From constructor parameters
// 'email': Ack.string.nullable() // From String? type
Override Annotation Examples
@Schema()
class AdvancedUser {
// Override: Make optional constructor param required in validation
@Required()
final String? nickname; // Optional in constructor, required in validation
// Override: Make non-nullable field accept null in validation
@Nullable()
final String internalId; // Non-nullable type, but validation allows null
AdvancedUser({
this.nickname, // Optional in constructor
required this.internalId, // Required in constructor
});
}
Manual Implementation (Advanced)
While code generation is recommended, you can also implement schemas manually:
class UserSchema extends SchemaModel<UserSchema> {
const UserSchema() : super();
const UserSchema._valid(Map<String, Object?> data) : super.valid(data);
@override
ObjectSchema get definition => Ack.object({
'name': Ack.string.minLength(2),
'email': Ack.string.email(),
'age': Ack.int.min(18),
}, required: ['name', 'email', 'age']);
@override
UserSchema parse(Object? data) {
final result = definition.validate(data);
if (result.isOk) {
final validatedData = Map<String, Object?>.from(
result.getOrThrow(),
);
return UserSchema._valid(validatedData);
}
throw AckException(result.getError());
}
// Add typed getters
String get name => getValue<String>('name')!;
String get email => getValue<String>('email')!;
int get age => getValue<int>('age')!;
}
// Usage
final schema = UserSchema().parse(data);
if (schema.isValid) {
final user = User(
name: schema.name,
email: schema.email,
age: schema.age,
);
}