Quick Start
Step 1: Define a Schema
A schema describes the expected structure and constraints of a value.
import 'package:zema/zema.dart';
final userSchema = z.object({
'id': z.integer(),
'name': z.string().min(2),
'email': z.string().email(),
'age': z.integer().gte(18).optional(),
'createdAt': z.dateTime(),
});
Each entry in the shape maps a field name to the schema that validates its value. Missing optional fields arrive as null and pass through. Missing required fields produce an invalid_type issue.
Step 2: Validate Data
safeParse validates the input and returns a ZemaResult. It never throws.
final data = {
'id': 1,
'name': 'Alice',
'email': 'alice@example.com',
'createdAt': '2024-01-15T10:30:00Z',
};
final ZemaResult<Map<String, dynamic>> result = userSchema.safeParse(data);
parse is the throwing variant. Use it when a validation failure is a programming error and a hard crash is the correct response.
// Throws ZemaException if validation fails
final Map<String, dynamic> value = userSchema.parse(data);
Step 3: Handle the Result
ZemaResult is a sealed class with two variants: ZemaSuccess and ZemaFailure. Use pattern matching or the when method to handle both cases exhaustively.
Pattern matching
switch (result) {
case ZemaSuccess(:final value):
print('ID: ${value['id']}');
print('Email: ${value['email']}');
case ZemaFailure(:final errors):
for (final issue in errors) {
print('${issue.pathString}: ${issue.message}');
}
}
when method
result.when(
success: (value) => print('User: ${value['name']}'),
failure: (errors) => print('${errors.length} issue(s) found'),
);
Imperative checks
if (result.isSuccess) {
final value = result.value; // Map<String, dynamic>
}
if (result.isFailure) {
final errors = result.errors; // List<ZemaIssue>
}
Step 4: Access Error Details
Each ZemaIssue carries a stable code, a human-readable message, and the path to the failing field.
// Invalid input
final badData = {
'id': 1,
'name': 'A', // too short (min 2)
'email': 'not-valid', // invalid email
'createdAt': '2024-01-15T10:30:00Z',
};
final result = userSchema.safeParse(badData);
if (result.isFailure) {
for (final issue in result.errors) {
print('[${issue.code}] ${issue.pathString}: ${issue.message}');
}
}
// [too_short] name: String is too short (min: 2, actual: 1)
// [invalid_email] email: Invalid email address
Step 5: Map to a Typed Model
When the output is a Map<String, dynamic>, use mapTo to apply a constructor without unwrapping the result manually.
// Freezed or hand-written model
class User {
final int id;
final String name;
final String email;
const User({required this.id, required this.name, required this.email});
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'] as int,
name: json['name'] as String,
email: json['email'] as String,
);
}
final ZemaResult<User> userResult = userSchema.safeParse(data).mapTo(User.fromJson);
mapTo forwards the failure unchanged if validation failed. User.fromJson is called only on success.
Step 6: Zero-Cost Typed Access with Extension Types
Extension Types wrap the validated map at compile time with zero runtime allocation.
extension type User(Map<String, dynamic> _) {
int get id => _['id'] as int;
String get name => _['name'] as String;
String get email => _['email'] as String;
int? get age => _['age'] as int?;
}
At runtime, User is the Map<String, dynamic> itself. No constructor call. No heap allocation. Type-safe field access is enforced at compile time.
if (result.isSuccess) {
final user = result.value;
print(user.email); // compile-time checked, zero-cost at runtime
}