Why Zema?
The Runtime Validation Gap
Dart's type system operates at compile time. It cannot verify the shape of data that arrives at runtime from external sources.
// The compiler sees Map<String, dynamic>. It cannot see the content.
final user = User.fromJson(response.data);
If response.data contains a String where an int is expected, the assignment compiles. The failure occurs at runtime, in production.
Failure Modes
Type mismatch
The backend returns a field with an incorrect type:
// API response
{
"id": "123", // String instead of int
"age": "twenty" // String instead of int
}
// Result: throws at runtime
// type 'String' is not a subtype of type 'int'
Missing required field
A new required field is added on the backend without a coordinated frontend update:
// API response — new field absent from the Dart model
{
"id": 1,
"email": "alice@example.com",
"role": "admin" // unknown field, silently ignored
}
The app processes invalid state without throwing. The field is lost.
Corrupted storage data
A bug writes a malformed document to Firestore or another persistence layer. Every subsequent read of that document fails at the deserialization step.
The Solution: Schema Validation
Zema intercepts data at the boundary between external sources and application logic. Validation runs before the data enters the type system.
final userSchema = z.object({
'id': z.integer(),
'email': z.string().email(),
'age': z.integer().gte(18),
});
final result = userSchema.safeParse(response.data);
switch (result) {
case ZemaSuccess(:final value):
// value is Map<String, dynamic> — every field has been validated
processUser(value);
case ZemaFailure(:final errors):
// errors is List<ZemaIssue> — each issue carries a code, message, and path
for (final issue in errors) {
logger.error('${issue.pathString}: ${issue.message}');
}
}
All field failures are collected in a single pass. The result carries the full error list, not just the first failure.
Relation to Freezed and json_serializable
Freezed and json_serializable provide compile-time model generation. They do not validate values at runtime.
// Freezed — no validation, throws on unexpected types
final user = User.fromJson(invalidData); // throws
Zema and Freezed serve different layers of the same pipeline. They compose:
// 1. Validate the raw map
final result = userSchema.safeParse(apiData);
// 2. Construct the Freezed model from the validated map
final user = result.mapTo(User.fromJson);
mapTo applies the constructor only if the result is a success. The Freezed model receives only valid, pre-checked data.
Comparison
| Capability | Zema | Freezed | json_serializable |
|---|---|---|---|
| Runtime validation | Yes | No | No |
| Code generation required | No | Yes | Yes |
| API boundary validation | Yes | No | No |
| Form validation | Yes | No | No |
| Composable schemas | Yes | No | No |
| Hot reload impact | None | Requires build_runner | Requires build_runner |
Applicable Contexts
Zema is applicable when:
- External API responses must be validated before use.
- Firestore documents may contain data written by older app versions.
- Form input must be validated against rules that go beyond type checks.
- A single schema definition must be reused across multiple validation sites.
Zema is not applicable when:
- All data sources are fully controlled and trusted.
- The validation overhead is measured and exceeds the acceptable budget.
Schema as Specification
A Zema schema is a machine-executable specification of a data contract. It documents constraints that no type annotation can express.
final userSchema = z.object({
'id': z.integer(),
'email': z.string().email(),
'role': z.string().oneOf(['admin', 'editor', 'viewer']),
'createdAt': z.dateTime(),
});
This schema states: id is an integer, email is a valid email address, role is one of three permitted strings, and createdAt is a parseable date/time. No external documentation is required.