Schemas

This guide covers schema types, validation, and working with validated data in Ack.

Overview

Ack provides a schema validation system built around the AckSchema base class. Use the Ack factory to create schemas, then validate data with the safeParse() method.

import 'package:ack/ack.dart';

// Define schema
final userSchema = Ack.object({
  'name': Ack.string().minLength(2),
  'age': Ack.integer().min(0),
  'email': Ack.string().email(),
});

// Validate data
final result = userSchema.safeParse({
  'name': 'John',
  'age': 30,
  'email': 'john@example.com',
});

if (result.isOk) {
  final validData = result.getOrThrow();
  print('Valid: ${validData['name']}');
} else {
  print('Error: ${result.getError()}');
}

Schema Types

String

// Basic string
final nameSchema = Ack.string();

// Enable strict type checking (no coercion)
final strictNameSchema = Ack.string().strictParsing();

// With constraints
final usernameSchema = Ack.string()
  .minLength(3)
  .maxLength(20)
  .matches(r'[a-zA-Z0-9_]+');

// Email validation
final emailSchema = Ack.string().email();

// URL validation
final websiteSchema = Ack.string().url();

// Date/datetime strings
final dateSchema = Ack.string().date(); // YYYY-MM-DD
final datetimeSchema = Ack.string().datetime(); // ISO 8601

// Enum values
final roleSchema = Ack.string().enumString(['admin', 'user', 'guest']);

Number

// Integer validation
final ageSchema = Ack.integer()
  .min(0)
  .max(120);

// Double validation
final priceSchema = Ack.double()
  .positive()
  .multipleOf(0.5); // Use factors that avoid floating point rounding issues

// Negative/positive
final temperatureSchema = Ack.integer(); // Any integer
final scoreSchema = Ack.double().positive(); // > 0
final debtSchema = Ack.double().negative(); // < 0

Boolean

final isActiveSchema = Ack.boolean();

List

// List of strings
final tagsSchema = Ack.list(Ack.string());

// With constraints
final itemsSchema = Ack.list(Ack.string())
  .minLength(1)
  .maxLength(10)
  .unique();

// List of objects
final usersSchema = Ack.list(Ack.object({
  'id': Ack.integer(),
  'name': Ack.string(),
}));

Object

The most common schema type for structured data:

final userSchema = Ack.object({
  'name': Ack.string(),
  'age': Ack.integer().min(0),
  'email': Ack.string().email(),
});

Nested Objects:

final userSchema = Ack.object({
  'name': Ack.string(),
  'address': Ack.object({
    'street': Ack.string(),
    'city': Ack.string(),
    'zipCode': Ack.string().matches(r'\d{5}'),
  }),
});

Working with Validated Data:

final result = userSchema.safeParse(data);

if (result.isOk) {
  final validData = result.getOrThrow();

  // Type cast when accessing
  final name = validData['name'] as String;
  final address = validData['address'] as Map<String, Object?>;
  final city = address['city'] as String;
}

Union Types

Validate against multiple possible schemas:

// String or integer
final idSchema = Ack.anyOf([
  Ack.string().strictParsing(),
  Ack.integer(),
]);

// Discriminated union (polymorphic data)
final shapeSchema = Ack.discriminated(
  discriminatorKey: 'type',
  schemas: {
    'circle': Ack.object({
      'type': Ack.literal('circle'),
      'radius': Ack.double().positive(),
    }),
    'rectangle': Ack.object({
      'type': Ack.literal('rectangle'),
      'width': Ack.double().positive(),
      'height': Ack.double().positive(),
    }),
  },
);

Any

Accept any value without validation (use sparingly):

final flexibleSchema = Ack.object({
  'id': Ack.string(),
  'metadata': Ack.any(), // Any value accepted
});

Optional vs Nullable

Understanding the difference is crucial:

.nullable() - Field must be present but can be null:

final userSchema = Ack.object({
  'name': Ack.string(),
  'middleName': Ack.string().nullable(),
});

// ✅ Valid
{'name': 'John', 'middleName': null}
{'name': 'John', 'middleName': 'Robert'}

// ❌ Invalid - middleName missing
{'name': 'John'}

.optional() - Field can be completely omitted (but is still validated when present):

final userSchema = Ack.object({
  'name': Ack.string(),
  'age': Ack.integer().optional(),
});

// ✅ Valid
{'name': 'John'} // age omitted
{'name': 'John', 'age': 30}

// ❌ Invalid
{'name': 'John', 'age': null} // Use .nullable() if null should be allowed

Combining both - Field can be missing OR null:

final userSchema = Ack.object({
  'name': Ack.string(),
  'bio': Ack.string().optional().nullable(),
});

// All valid:
{'name': 'John'}
{'name': 'John', 'bio': null}
{'name': 'John', 'bio': 'Developer'}

Object Schema Operations

Extension

Add or override properties:

final baseSchema = Ack.object({
  'id': Ack.string(),
  'name': Ack.string(),
});

// Add properties
final extendedSchema = baseSchema.extend({
  'email': Ack.string().email(),
  'role': Ack.literal('admin'),
});

// Override properties
final modifiedSchema = baseSchema.extend({
  'name': Ack.string().optional(), // Make name optional
});

Pick and Omit

Select or exclude properties:

final fullSchema = Ack.object({
  'id': Ack.string(),
  'name': Ack.string(),
  'email': Ack.string().email(),
  'password': Ack.string(),
  'createdAt': Ack.string().datetime(),
});

// Pick specific fields
final publicSchema = fullSchema.pick(['id', 'name', 'email']);

// Omit sensitive fields
final safeSchema = fullSchema.omit(['password']);

Partial

Make all properties optional:

final userSchema = Ack.object({
  'name': Ack.string(),
  'email': Ack.string().email(),
  'age': Ack.integer(),
});

// All fields become optional
final partialSchema = userSchema.partial();

// All valid:
partialSchema.safeParse({});
partialSchema.safeParse({'name': 'John'});
partialSchema.safeParse({'email': 'john@example.com', 'age': 30});

Additional Properties

Control handling of extra properties not defined in the schema. By default, objects are strict and reject additional properties.

Using Constructor Parameter

// Strict mode (default) - rejects additional properties
final strictSchema = Ack.object({
  'id': Ack.string(),
  'name': Ack.string(),
}); // additionalProperties: false is the default

strictSchema.safeParse({'id': '1', 'name': 'John', 'extra': 'value'}); // ❌ Fails

// Passthrough mode - allows additional properties
final flexibleSchema = Ack.object({
  'id': Ack.string(),
  'name': Ack.string(),
}, additionalProperties: true);

flexibleSchema.safeParse({'id': '1', 'name': 'John', 'extra': 'allowed'}); // ✅ Passes

Using Extension Methods

final baseSchema = Ack.object({
  'id': Ack.string(),
  'name': Ack.string(),
});

// Make strict (reject extra properties)
final strict = baseSchema.strict();
strict.safeParse({'id': '1', 'name': 'John', 'role': 'admin'}); // ❌ Fails

// Allow passthrough (accept extra properties)
final passthrough = baseSchema.passthrough();
passthrough.safeParse({'id': '1', 'name': 'John', 'role': 'admin'}); // ✅ Passes

Common Use Cases:

  • Strict mode: API request validation, form validation where only known fields are allowed
  • Passthrough mode: Working with dynamic data, gradual migration, or when extra metadata is acceptable

Custom Validation

Refinements

Add custom validation logic:

// Password confirmation
final passwordSchema = Ack.object({
  'password': Ack.string().minLength(8),
  'confirmPassword': Ack.string(),
}).refine(
  (data) => data['password'] == data['confirmPassword'],
  message: 'Passwords must match',
);

// Business logic validation
final orderSchema = Ack.object({
  'items': Ack.list(Ack.object({
    'price': Ack.double(),
    'quantity': Ack.integer(),
  })),
  'total': Ack.double(),
}).refine(
  (order) {
    final items = order['items'] as List;
    final calculatedTotal = items.fold<double>(0, (sum, item) {
      final itemMap = item as Map<String, Object?>;
      final price = itemMap['price'] as double;
      final qty = itemMap['quantity'] as int;
      return sum + (price * qty);
    });
    final total = order['total'] as double;
    return (calculatedTotal - total).abs() < 0.01;
  },
  message: 'Total must match sum of items',
);

Transformations

Transform validated data:

// Transform to uppercase
final upperSchema = Ack.string().transform((s) => s?.toUpperCase() ?? '');

// Add computed fields
final userWithAgeSchema = Ack.object({
  'name': Ack.string(),
  'birthYear': Ack.integer(),
}).transform((data) {
  final birthYear = data!['birthYear'] as int;
  final age = DateTime.now().year - birthYear;
  return {...data, 'age': age};
});

// Type transformation
final dateSchema = Ack.string()
  .matches(r'\d{4}-\d{2}-\d{2}')
  .transform<DateTime>((s) => DateTime.parse(s!));

Validation Result

The safeParse() method returns a SchemaResult:

final result = schema.safeParse(data);

// Check success
if (result.isOk) {
  final data = result.getOrThrow();
}

// Check failure
if (result.isFail) {
  final error = result.getError();
}

// Other methods
final data = result.getOrNull(); // null if failed
final data = result.getOrElse(() => defaultValue); // default if failed

See the Error Handling guide for details on working with errors.

Next Steps

Explore related topics to deepen your understanding: