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:

  1. Separate validation logic from your models
  2. Manual type casting between dynamic JSON and typed models
  3. Duplicate schema definitions for documentation and client validation

TypeSafe Schemas solves these problems by combining:

  1. Strong typing through generated schema classes with typed getters
  2. Automatic validation when creating schemas from external data
  3. Schema generation for documentation and cross-platform validation

All from a single model definition with validation annotations.

Getting Started

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.

Key Capabilities

TypeSafe Schemas provide three key capabilities from a single model definition:

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,
  );
}