Zema Logo
Validations

Basic Validation

parse() throws on failure, safeParse() returns ZemaResult. Full reference for validation methods, result handling, and ZemaResult utilities.

Every schema exposes four validation methods. Two are synchronous, two are async.

MethodReturnsThrows
parse(value)TZemaException 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 more ZemaIssues.

Properties available on both variants:

PropertyTypeOn successOn failure
isSuccessbooltruefalse
isFailureboolfalsetrue
valueTvalidated valuethrows StateError
errorsList<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

Copyright © 2026