Error Handling
ZemaIssue
Every validation failure produces a ZemaIssue. It carries six fields:
final class ZemaIssue {
final String code; // stable identifier for the failure type
final String message; // human-readable description
final List<Object> path; // location: String keys and int array indices
final Object? receivedValue; // the value that failed (may be null)
final String? expected; // the expected type/value (e.g. 'int', 'email')
final Map<String, dynamic>? meta; // interpolation data used by i18n
final ZemaSeverity severity; // ZemaSeverity.error (default) or .warning
}
Properties
| Property | Type | Description |
|---|---|---|
code | String | Stable string identifier. Use this in logic, not message. |
message | String | Human-readable, locale-aware description. |
path | List<Object> | Field path: String for keys, int for array indices. Empty at root. |
receivedValue | Object? | The raw value that failed. May be null. |
expected | String? | The expected type or value. Set on invalid_type and similar issues. |
meta | Map<String, dynamic>? | Interpolation data used by translations and error maps. |
severity | ZemaSeverity | error (blocks parse) or warning (informational, parse succeeds). |
pathString | String | Formatted path: 'root', 'email', 'users.[1].email'. |
Example
final schema = z.object({
'user': z.object({
'email': z.string().email(),
}),
});
final result = schema.safeParse({
'user': {'email': 'not-an-email'},
});
final issue = result.errors.first;
issue.code // 'invalid_email'
issue.message // 'Invalid email format'
issue.path // ['user', 'email']
issue.pathString // 'user.email'
issue.receivedValue // 'not-an-email'
issue.severity // ZemaSeverity.error
ZemaMetaKeys — type-safe interpolation keys
The meta map keys used by built-in translations are available as compile-time constants to prevent silent typos:
// Fragile — typo is invisible:
ZemaIssue(code: 'too_short', message: '...', meta: {'minn': 2}) // 'minn' → null in translation
// Safe — typo caught at compile time:
ZemaIssue(code: 'too_short', message: '...', meta: {ZemaMetaKeys.min: 2})
| Constant | Key | Used in |
|---|---|---|
ZemaMetaKeys.min | 'min' | too_short, too_small, date_too_early |
ZemaMetaKeys.max | 'max' | too_long, too_big, date_too_late |
ZemaMetaKeys.actual | 'actual' | most constraint violations |
ZemaMetaKeys.expected | 'expected' | invalid_type, invalid_literal |
ZemaMetaKeys.received | 'received' | invalid_type |
ZemaMetaKeys.allowed | 'allowed' | invalid_enum |
ZemaMetaKeys.type | 'type' | invalid_coercion |
ZemaMetaKeys.length | 'length' | wrong_length |
ZemaMetaKeys.pattern | 'pattern' | invalid_format |
ZemaMetaKeys.multipleOf | 'multipleOf' | not_multiple_of |
Severity and warnings
Issues produced by .refineWarn() have severity == ZemaSeverity.warning. These surface in ZemaResult.warnings on a successful parse:
final result = passwordSchema.safeParse('hello123');
print(result.isSuccess); // true
print(result.hasWarnings); // true
for (final w in result.warnings) {
print('${w.code}: ${w.message}'); // advisory message
}
Error codes
Codes are plain String constants, not an enum. Use them in conditional logic:
switch (issue.code) {
case 'invalid_email':
showError('Please enter a valid email.');
case 'too_short':
showError('Input is too short.');
case 'custom_error':
showError(issue.message);
default:
showError('Validation failed.');
}
Built-in codes
| Code | Trigger |
|---|---|
invalid_type | Wrong Dart type |
too_short | String below min length |
too_long | String above max length |
wrong_length | String not exact length |
too_small | Number below gte/gt bound |
too_big | Number above lte/lt bound |
too_small_exclusive | Number not above gt bound |
too_big_exclusive | Number not below lt bound |
not_positive | Number not positive |
not_negative | Number not negative |
not_finite | Infinity or NaN |
not_multiple_of | Number not a multiple |
invalid_email | Failed email format |
invalid_url | Failed URL format |
invalid_uuid | Failed UUID format |
invalid_format | Failed regex |
invalid_enum | Not in allowed values |
invalid_literal | Not the exact literal |
invalid_date | Not a valid DateTime |
date_too_early | DateTime before min |
date_too_late | DateTime after max |
invalid_union | No union member matched |
invalid_coercion | Coercion failed |
transform_error | Transformer threw |
required | Required field missing |
custom_error | .refine() returned false |
custom_validation_failed | z.custom() returned false |
async_custom_error | .refineAsync() returned false |
async_refinement_error | .refineAsync() threw |
Error paths
path is a List<Object> where each segment is either a String (object key) or an int (array index). Use pathString to get a formatted string:
| Path | pathString |
|---|---|
[] | 'root' |
['email'] | 'email' |
['user', 'email'] | 'user.email' |
['items', 2, 'name'] | 'items.[2].name' |
Nested object
final schema = z.object({
'user': z.object({
'profile': z.object({
'email': z.string().email(),
}),
}),
});
final result = schema.safeParse({
'user': {'profile': {'email': 'invalid'}},
});
result.errors.first.path // ['user', 'profile', 'email']
result.errors.first.pathString // 'user.profile.email'
Array
final schema = z.array(z.integer());
final result = schema.safeParse([1, 'two', 3]);
result.errors.first.path // [1] (int index)
result.errors.first.pathString // '[1]'
Nested array
final schema = z.object({
'users': z.array(z.object({'email': z.string().email()})),
});
final result = schema.safeParse({
'users': [
{'email': 'valid@example.com'},
{'email': 'invalid'},
],
});
result.errors.first.path // ['users', 1, 'email']
result.errors.first.pathString // 'users.[1].email'
ZemaException
parse() and parseAsync() throw ZemaException on failure. It exposes the issue list via issues:
try {
final user = userSchema.parse(data);
saveToDatabase(user);
} on ZemaException catch (e) {
for (final issue in e.issues) {
print('${issue.pathString}: ${issue.message}');
}
}
Note: the property is issues, not errors.
List<ZemaIssue> extensions
result.errors is a List<ZemaIssue>. Several extensions are available directly on it:
.flatten()
All messages as a flat list of strings:
result.errors.flatten();
// ['Invalid email format', 'Must be >= 18']
.groupByPath()
Groups messages by pathString:
result.errors.groupByPath();
// {
// 'email': ['Invalid email format'],
// 'age': ['Must be >= 18'],
// }
.format()
Nested map with _errors keys, matching the input structure:
result.errors.format();
// {
// 'user': {
// 'email': {'_errors': ['Invalid email format']},
// 'age': {'_errors': ['Must be >= 18']},
// }
// }
.errorsAt(path) / .firstErrorAt(path)
Get errors for a specific field:
result.errors.errorsAt(['email']); // ['Invalid email format'] or null
result.errors.firstErrorAt(['email']); // 'Invalid email format' or null
result.errors.hasErrorsAt(['email']); // true / false
Displaying errors to users
Group by field for a form
final result = schema.safeParse(formData);
if (result.isFailure) {
final byPath = result.errors.groupByPath();
emailField.error = byPath['email']?.first;
passwordField.error = byPath['password']?.first;
ageField.error = byPath['age']?.first;
}
Collect all messages
final messages = result.errors.flatten().join('\n');
showDialog(content: Text(messages));
First error only
if (result.isFailure) {
final first = result.errors.first;
showSnackBar('${first.pathString}: ${first.message}');
}
Custom error messages
Per constraint
Override the message for a specific constraint using the message: parameter:
z.string()
.min(5, message: 'Username must be at least 5 characters.')
.max(20, message: 'Username cannot exceed 20 characters.')
.regex(
RegExp(r'^[a-zA-Z0-9_]+$'),
message: 'Only letters, numbers, and underscores.',
);
Per schema with .refine()
z.object({
'startDate': z.dateTime(),
'endDate': z.dateTime(),
}).refine(
(data) => (data['endDate'] as DateTime).isAfter(data['startDate'] as DateTime),
message: 'End date must be after start date.',
code: 'invalid_range',
);
Global error map
ZemaErrorMap.setErrorMap() intercepts every issue before it is returned. Return a String to override the message, or null to keep the default:
ZemaErrorMap.setErrorMap((issue, ctx) {
// ctx.code, ctx.defaultMessage, ctx.meta are available
switch (issue.code) {
case 'invalid_email':
return 'Please enter a valid email address.';
case 'too_short':
final min = issue.meta?['min'];
return min != null ? 'Must be at least $min characters.' : null;
default:
return null; // keep default
}
});
Clear the map to restore defaults:
ZemaErrorMap.clearErrorMap();
i18n
Set locale globally
ZemaErrorMap.setLocale('fr'); // all subsequent messages use French
Built-in locales: 'en' (default), 'fr'.
Translate issues at call site
final translated = result.errors.translate();
// same List<ZemaIssue> with messages in the current locale
Register a custom locale
ZemaI18n.registerTranslations('de', {
'invalid_email': (_) => 'Ungültige E-Mail-Adresse.',
'too_short': (p) => 'Mindestens ${p?['min']} Zeichen erforderlich.',
'too_big': (p) => 'Darf maximal ${p?['max']} betragen.',
// add entries for any code you need to translate
});
ZemaErrorMap.setLocale('de');
The translation function receives Map<String, dynamic>? meta with interpolation values (min, max, expected, etc.). Return null or omit a code to fall back to English.
API reference
ZemaIssue
| Property | Type |
|---|---|
code | String |
message | String |
path | List<Object> |
receivedValue | Object? |
meta | Map<String, dynamic>? |
pathString | String (getter) |
ZemaException
| Property | Type |
|---|---|
issues | List<ZemaIssue> |
List<ZemaIssue> extensions
| Method | Returns | Description |
|---|---|---|
.flatten() | List<String> | All messages |
.groupByPath() | Map<String, List<String>> | Messages keyed by pathString |
.format() | Map<String, dynamic> | Nested map with _errors keys |
.errorsAt(path) | List<String>? | Messages at a specific path |
.firstErrorAt(path) | String? | First message at a specific path |
.hasErrorsAt(path) | bool | Whether a path has errors |
.translate() | List<ZemaIssue> | Issues re-messaged with current locale |
.applyErrorMap() | List<ZemaIssue> | Issues run through global error map |
ZemaErrorMap
| Method | Description |
|---|---|
setErrorMap(fn) | Set global message override function |
clearErrorMap() | Remove global error map |
setLocale(locale) | Set active locale ('en', 'fr', or custom) |
locale | Current locale (getter) |
ZemaI18n
| Method | Description |
|---|---|
registerTranslations(locale, map) | Register a new locale |
translate(code, {params}) | Translate a code with interpolation |
Next steps
- Basic Validation:
safeParse(),ZemaResult, and result handling - Custom Validators:
.refine(),.superRefine(), andz.custom() - Async Validation:
safeParseAsync()and.refineAsync()