Basic Validation
Every schema exposes four validation methods. Two are synchronous, two are async.
| Method | Returns | Throws |
|---|---|---|
parse(value) | T | ZemaException on failure |
safeParse(value) | ZemaResult<T> | Never |
parseAsync(value) | Future<T> | ZemaException on failure |
safeParseAsync(value) | Future<ZemaResult<T>> | Never |
Prefer safeParse() when you need to handle errors without try/catch. Use parse() when a failure is a programming error and should propagate as an exception.
parse()
Returns the validated value directly. Throws ZemaException if validation fails.
final schema = z.string();
final value = schema.parse('hello'); // 'hello' (type: String)
schema.parse(42); // throws ZemaException: invalid_type
Wrap in try/catch when failure is expected:
try {
final user = userSchema.parse(data);
saveToDatabase(user);
} on ZemaException catch (e) {
for (final issue in e.issues) {
print('${issue.path.join('.')}: ${issue.message}');
}
}
safeParse()
Returns a ZemaResult<T>. Never throws. Use when you want to inspect both the success and failure branches without try/catch.
final schema = z.string();
final result = schema.safeParse('hello'); // ZemaResult<String>
final result2 = schema.safeParse(42); // ZemaResult<String> (failure)
ZemaResult<T>
A sealed class with two variants:
ZemaSuccess<T>: holds the validated value.ZemaFailure<T>: holds one or moreZemaIssues.
Properties available on both variants:
| Property | Type | On success | On failure |
|---|---|---|---|
isSuccess | bool | true | false |
isFailure | bool | false | true |
value | T | validated value | throws StateError |
errors | List<ZemaIssue> | [] | list of issues |
final result = z.string().safeParse('hello');
result.isSuccess // true
result.value // 'hello'
result.errors // []
final result2 = z.integer().safeParse('oops');
result2.isFailure // true
result2.errors // [ZemaIssue(code: 'invalid_type', ...)]
result2.value // throws StateError: check isSuccess first
Handling results
Pattern matching
The recommended approach. Exhaustive by construction: the compiler requires both cases.
final result = userSchema.safeParse(data);
switch (result) {
case ZemaSuccess(:final value):
print('Valid: ${value['email']}');
case ZemaFailure(:final errors):
for (final issue in errors) {
print('${issue.path.join('.')}: ${issue.message}');
}
}
when()
Expression-oriented exhaustive handler. Exactly one branch is called and its return value is returned.
final message = result.when(
success: (value) => 'Hello, ${value['name']}!',
failure: (errors) => 'Error: ${errors.first.message}',
);
maybeWhen()
Like when(), but neither success nor failure is required. Unhandled cases fall through to orElse.
final label = result.maybeWhen(
success: (value) => 'Valid: $value',
orElse: () => 'Something went wrong',
);
onSuccess() / onError()
Side-effect callbacks. Both return void. Use cascade (..) to call both on the same result.
result
..onSuccess((value) => cache.store(value))
..onError((issues) => logger.warn(issues));
If/else
Simple conditional when only one branch is needed:
final result = schema.safeParse(data);
if (result.isSuccess) {
print(result.value);
} else {
print('Failed: ${result.errors}');
}
Mapping to typed models
When the schema output is Map<String, dynamic>, use the mapTo family to convert it to a typed class without unwrapping manually.
mapTo(mapper)
Keeps the ZemaResult wrapper. Mapper is only called on success. Errors are forwarded unchanged.
final result = userSchema.safeParse(json).mapTo(User.fromJson);
// result is ZemaResult<User>
switch (result) {
case ZemaSuccess(:final value): print(value.email); // User
case ZemaFailure(:final errors): print(errors);
}
mapToOrElse(mapper, onError:)
Unwraps entirely. Returns R directly from either branch.
final user = result.mapToOrElse(
User.fromJson,
onError: (issues) => User.empty(),
);
// user is User (not ZemaResult<User>)
mapToOrNull(mapper)
Returns null on failure instead of a failure variant.
final user = result.mapToOrNull(User.fromJson);
// user is User? (null if validation failed)
if (user != null) { ... }
Error collection
ZemaObject validates all fields before returning. A single parse call can produce multiple issues, one per failed field.
final schema = z.object({
'email': z.string().email(),
'age': z.integer().gte(18),
'name': z.string().gte(2),
});
final result = schema.safeParse({
'email': 'invalid', // fails: invalid_email
'age': 15, // fails: too_small
'name': 'A', // fails: too_short
});
result.errors.length // 3 (all issues collected in one pass)
for (final issue in result.errors) {
print('${issue.path.join('.')}: ${issue.message}');
}
// email: Invalid email address
// age: Must be at least 18
// name: Must be at least 2 characters
Primitive schemas fail fast: they produce one issue and stop. Only ZemaObject and ZemaArray collect multiple issues.
Common patterns
API response
Future<User> fetchUser(int id) async {
final response = await dio.get('/users/$id');
final result = userSchema.safeParse(response.data);
return result.mapToOrElse(
User.fromJson,
onError: (issues) => throw ApiValidationException(issues),
);
}
Form validation
void submitForm() {
final result = loginSchema.safeParse({
'email': emailController.text,
'password': passwordController.text,
});
switch (result) {
case ZemaSuccess(:final value):
login(value);
case ZemaFailure(:final errors):
for (final issue in errors) {
showFieldError(issue.path.first, issue.message);
}
}
}
Validate before saving
void saveUser(Map<String, dynamic> data) {
final result = userSchema.safeParse(data);
if (result.isFailure) {
throw ArgumentError('Invalid user: ${result.errors}');
}
firestore.collection('users').add(result.value);
}
Log and fall back
final result = configSchema.safeParse(rawConfig);
result
..onError((issues) => logger.warn('Invalid config, using defaults: $issues'))
..onSuccess((value) => applyConfig(value));
Schema reuse
Schemas are immutable and stateless. Define them once and reuse across calls:
// Define once (e.g. top-level or in a service class)
final userSchema = z.object({
'id': z.integer(),
'email': z.string().email(),
});
// Reuse without re-allocating
for (final raw in rawUsers) {
userSchema.safeParse(raw);
}
Next steps
- Error Handling:
ZemaIssue,ZemaException, error codes, and i18n - Async Validation:
safeParseAsync()andrefineAsync()