Zema Logo
Validations

Error Handling

ZemaIssue structure, error codes, path format, List<ZemaIssue> extensions, ZemaException, custom error messages, and i18n.

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

PropertyTypeDescription
codeStringStable string identifier. Use this in logic, not message.
messageStringHuman-readable, locale-aware description.
pathList<Object>Field path: String for keys, int for array indices. Empty at root.
receivedValueObject?The raw value that failed. May be null.
expectedString?The expected type or value. Set on invalid_type and similar issues.
metaMap<String, dynamic>?Interpolation data used by translations and error maps.
severityZemaSeverityerror (blocks parse) or warning (informational, parse succeeds).
pathStringStringFormatted 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})
ConstantKeyUsed 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

CodeTrigger
invalid_typeWrong Dart type
too_shortString below min length
too_longString above max length
wrong_lengthString not exact length
too_smallNumber below gte/gt bound
too_bigNumber above lte/lt bound
too_small_exclusiveNumber not above gt bound
too_big_exclusiveNumber not below lt bound
not_positiveNumber not positive
not_negativeNumber not negative
not_finiteInfinity or NaN
not_multiple_ofNumber not a multiple
invalid_emailFailed email format
invalid_urlFailed URL format
invalid_uuidFailed UUID format
invalid_formatFailed regex
invalid_enumNot in allowed values
invalid_literalNot the exact literal
invalid_dateNot a valid DateTime
date_too_earlyDateTime before min
date_too_lateDateTime after max
invalid_unionNo union member matched
invalid_coercionCoercion failed
transform_errorTransformer threw
requiredRequired field missing
custom_error.refine() returned false
custom_validation_failedz.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:

PathpathString
[]'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

PropertyType
codeString
messageString
pathList<Object>
receivedValueObject?
metaMap<String, dynamic>?
pathStringString (getter)

ZemaException

PropertyType
issuesList<ZemaIssue>

List<ZemaIssue> extensions

MethodReturnsDescription
.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)boolWhether a path has errors
.translate()List<ZemaIssue>Issues re-messaged with current locale
.applyErrorMap()List<ZemaIssue>Issues run through global error map

ZemaErrorMap

MethodDescription
setErrorMap(fn)Set global message override function
clearErrorMap()Remove global error map
setLocale(locale)Set active locale ('en', 'fr', or custom)
localeCurrent locale (getter)

ZemaI18n

MethodDescription
registerTranslations(locale, map)Register a new locale
translate(code, {params})Translate a code with interpolation

Next steps

Copyright © 2026