Custom Validation Rules

While Ack provides many built-in validation rules, you can easily define your own custom validation logic using SchemaConstraint.

Creating a SchemaConstraint

To create a custom rule, extend the abstract SchemaConstraint<T> class, where T is the data type the constraint applies to (e.g., String, int, num, List, Map, Object?).

You need to implement:

  1. Constructor: Call super() with a unique name (used internally and potentially in errors) and a default message for validation failures.
  2. validate(T value, [Map<String, dynamic>? data]) method: This method contains your validation logic.
    • It receives the value being validated.
    • It optionally receives the entire data map (useful for cross-field validation).
    • It must return true if the value is valid according to this constraint, and false otherwise.
import 'package:ack/ack.dart';

// Example 1: Constraint for positive numbers (applies to `num`)
class IsPositiveConstraint extends SchemaConstraint<num> {
  // Provide a unique name and a default error message
  IsPositiveConstraint() : super(name: 'is_positive', message: 'Number must be positive');

  // Implement the validation logic
  @override
  bool validate(num value, [Map<String, dynamic>? data]) {
    // Return true if the value is valid, false otherwise
    return value > 0;
  }
}

// Example 2: Constraint for password matching (applies to `String`)
class PasswordMatchConstraint extends SchemaConstraint<String> {
  final String fieldToMatch;

  PasswordMatchConstraint(this.fieldToMatch) 
      : super(name: 'password_match', message: 'Passwords do not match');

  @override
  bool validate(String value, [Map<String, dynamic>? data]) {
    // Access the other field's value from the optional data map
    // This map contains the full object being validated by the parent Ack.object schema.
    final otherPassword = data?[fieldToMatch] as String?;
    return value == otherPassword;
  }
}

Applying Custom Constraints

Use the .constrain() method on any schema type to apply your custom SchemaConstraint.

// Applying the IsPositiveConstraint to a double schema
final priceSchema = Ack.double.constrain(IsPositiveConstraint());

print(priceSchema.validate(10.50).isOk); // true
print(priceSchema.validate(-5.0).isOk);  // false
print(priceSchema.validate(-5.0).getError()?.message); // Number must be positive


// Applying the PasswordMatchConstraint within an object schema
// See: [Object Schema](../core-concepts/schemas.mdx#object-schema)
final signUpSchema = Ack.object({
  'password': Ack.string.minLength(8),
  'confirmPassword': Ack.string
      .minLength(8)
      // Apply the constraint, passing the name of the field to compare against
      .constrain(PasswordMatchConstraint('password')) 
}, required: ['password', 'confirmPassword']);

// Valid data
final validPasswords = {
  'password': 'pass1234',
  'confirmPassword': 'pass1234'
};
print(signUpSchema.validate(validPasswords).isOk); // true

// Invalid data
final mismatchPasswords = {
  'password': 'pass1234',
  'confirmPassword': 'different'
};
final result = signUpSchema.validate(mismatchPasswords);
print(result.isOk); // false
print(result.getError()?.path); // ['confirmPassword']
print(result.getError()?.message); // Passwords do not match

Overriding Error Messages

You can override the default message defined in your SchemaConstraint constructor when applying it by providing the optional message parameter to .constrain(). See also: Custom Error Messages.

final schema = Ack.double
    .constrain(IsPositiveConstraint(), message: 'Price must be greater than zero.');

print(schema.validate(-10).getError()?.message); // Price must be greater than zero.

Reusable Constraints

Define your custom constraints in separate files or a utility library to reuse them across different schemas in your application.

// file: validation/constraints.dart
import 'package:ack/ack.dart';

class IsPositiveConstraint extends SchemaConstraint<num> { /* ... */ }
class PasswordMatchConstraint extends SchemaConstraint<String> { /* ... */ }

// file: schemas/user_schema.dart
import 'package:ack/ack.dart';
import '../validation/constraints.dart';

final userSchema = Ack.object({
  'age': Ack.int.constrain(IsPositiveConstraint()),
  // ... other fields
});

When to Use Custom Constraints

  • Complex Business Logic: Validation rules specific to your application domain.
  • Cross-Field Validation: Rules that depend on the values of multiple fields (like password confirmation).
  • Reusable Patterns: Common validation patterns used in multiple schemas.
  • External Service Validation: Checking a value against an external API or database (though be mindful of performance implications).

For simple cases, chaining built-in constraints is often sufficient. Use custom constraints when the built-in rules don't cover your specific requirements.