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()}');
}
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
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'}
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
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:
- Validation Rules: Master all built-in constraints and strict parsing
- Error Handling: Handle validation errors effectively
- Custom Validation: Create custom constraints and refinements
- JSON Serialization: Validate and transform JSON data
- Common Recipes: See practical schema patterns