SchemaModel Class API
This document describes the SchemaModel
API in Ack, which provides a way to create schema-based models with automatic validation. While you can implement SchemaModel classes manually, the recommended approach is to use code generation.
Overview
The SchemaModel
class is a base class for creating schema-based models that can validate data against a schema. It provides a simple and intuitive API for creating, validating, and accessing typed data.
Key Features
- Automatic Validation: Validation happens automatically when a SchemaModel is created
- Simple Error Handling: Easy access to validation errors through
isValid
andgetErrors()
- Type Safety: Uses
Object?
instead ofdynamic
for better type safety - Code Generation: Generate schema models automatically from annotated classes
- Direct Property Access: Access validated data directly through typed getters
Using Code Generation (Recommended Approach)
The easiest way to use SchemaModel is with code generation. This approach lets you define your models as regular Dart classes with annotations, and the generator creates the corresponding SchemaModel classes for you.
1. Define Your Model Class
// file: user.dart
import 'package:ack/ack.dart';
@Schema()
class User {
@MinLength(2)
final String name;
@IsEmail()
final String email;
@Min(0)
final int? age;
User({required this.name, required this.email, 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.schema.dart
file with a UserSchema
class that extends SchemaModel
.
Approach 1: Direct Property Access
// file: main.dart
import 'user.dart';
import 'user.schema.dart'; // Import the generated file
void main() {
final userData = {
'name': 'John Doe',
'email': 'john@example.com',
'age': 30,
};
// Create schema instance - validation happens with parse method
final userSchema = UserSchema().parse(userData);
// Check if the data is valid
if (userSchema.isValid) {
// Access properties directly from the schema
print('Valid User: ${userSchema.name}, ${userSchema.email}, Age: ${userSchema.age}');
// Create a User model if needed
final user = User(
name: userSchema.name,
email: userSchema.email,
age: userSchema.age,
);
}
}
Approach 2: Factory Method Pattern
// file: user.dart
class User {
final String name;
final String email;
final int? age;
User({required this.name, required this.email, this.age});
// Add a factory method to create from schema
factory User.fromSchema(UserSchema schema) {
if (!schema.isValid) {
throw AckException(schema.getErrors()!);
}
return User(
name: schema.name,
email: schema.email,
age: schema.age,
);
}
}
// Usage
void main() {
final userSchema = UserSchema().parse(userData);
final user = User.fromSchema(userSchema); // Throws if invalid
}
Approach 3: Extension Methods
// file: user_extensions.dart
extension UserSchemaExtensions on UserSchema {
User toUser() {
if (!isValid) {
throw AckException(getErrors()!);
}
return User(
name: name,
email: email,
age: age,
);
}
}
// Usage
void main() {
final userSchema = UserSchema().parse(userData);
if (userSchema.isValid) {
final user = userSchema.toUser();
}
}
Generated Schema Class API
When you use code generation, the generated schema class extends the base SchemaModel class and adds typed property getters, static methods and properties for convenience. Here's what you'll find in a generated schema class:
ObjectSchema get definition
Returns the ObjectSchema
instance that defines the validation rules for the model, generated from your annotations.
final userSchema = UserSchema().definition;
print('Schema: ${userSchema}');
Map<String, Object?> toJsonSchema()
Converts the schema to a JSON Schema as a Map.
final jsonSchema = UserSchema().toJsonSchema();
print('JSON Schema: $jsonSchema');
// Convert to JSON string if needed
final jsonString = jsonEncode(jsonSchema);
print('JSON String:\n$jsonString');
Instance Properties
The generated schema provides typed getters for each property in your model:
final schema = UserSchema().parse(userData);
if (schema.isValid) {
String name = schema.name; // Non-nullable property
String email = schema.email; // Non-nullable property
int? age = schema.age; // Nullable property
}
Map<String, dynamic> toMap()
Converts the schema instance to a map, useful for serialization.
final schema = UserSchema().parse(userData);
if (schema.isValid) {
final map = schema.toMap();
final json = jsonEncode(map);
print('JSON: $json');
}
bool get isValid
Returns whether the data is valid according to the schema.
final schema = UserSchema().parse(userData);
if (schema.isValid) {
// Use schema...
}
SchemaError? getErrors()
Returns the validation errors if the data is invalid, or null
if the data is valid.
final schema = UserSchema().parse(userData);
if (!schema.isValid) {
print('Errors: ${schema.getErrors()}');
}
Working with Nested Models
The code generator automatically handles nested models if the nested type is also annotated with @Schema
.
// file: address.dart
import 'package:ack/ack.dart';
@Schema()
class Address {
final String street;
final String city;
Address({required this.street, required this.city});
factory Address.fromSchema(AddressSchema schema) {
return Address(
street: schema.street,
city: schema.city,
);
}
}
// file: user.dart
import 'package:ack/ack.dart';
import 'address.dart';
@Schema()
class User {
final String name;
final Address address;
User({required this.name, required this.address});
factory User.fromSchema(UserSchema schema) {
return User(
name: schema.name,
address: Address.fromSchema(schema.address),
);
}
}
When you run build_runner
, it generates both AddressSchema
and UserSchema
classes. The UserSchema
will automatically use AddressSchema
for validating the nested address
field.
final userData = {
'name': 'John Doe',
'address': {
'street': '123 Main St',
'city': 'Anytown',
},
};
// Create schema instance - validation happens automatically
final userSchema = UserSchema(userData);
// Check if valid and access properties
if (userSchema.isValid) {
print('User: ${userSchema.name}, City: ${userSchema.address.city}');
// Create model if needed
final user = User.fromSchema(userSchema);
}
Manual Implementation (Advanced)
While code generation is recommended, you can also implement SchemaModel
manually for more control. The manual implementation follows the same pattern as the base SchemaModel class, with validation happening automatically in the constructor.
class UserSchema extends SchemaModel {
UserSchema(Object? data) : super(data);
@override
AckSchema getSchema() {
return Ack.object(
{
'name': Ack.string.minLength(2),
'email': Ack.string.email(),
'age': Ack.int.min(0).nullable(),
},
required: ['name', 'email'],
);
}
// Add typed getters
String get name => getValue<String>('name')!;
String get email => getValue<String>('email')!;
int? get age => getValue<int?>('age');
}
SchemaRegistry
The SchemaRegistry
is an optional utility for managing SchemaModel
factories. It allows you to create SchemaModel
instances dynamically based on a schema type.
// Register a factory for creating UserSchema instances
SchemaRegistry.register<UserSchema>((data) => UserSchema(data));
// Create a UserSchema instance using the registered factory
final schema = SchemaRegistry.createSchema<UserSchema>(userData);
if (schema != null && schema.isValid) {
print('Name: ${schema.name}');
// Create model using your preferred pattern
final user = User(name: schema.name, email: schema.email);
}
Note: Using SchemaRegistry
is optional and useful in scenarios where you need to handle schema creation dynamically.
Migration from v1.x
If you're upgrading from v1.x where schemas had a toModel()
method, see the migration patterns shown above. The key change is that schemas no longer create model instances - they only validate and provide typed access to data. This gives you complete control over how you create your model instances.