Zema Logo
Validations

Async Validation

Validate against external services with .refineAsync() and safeParseAsync(). Covers async predicates, error handling, timeouts, and caching.

Async validation lets a refinement perform I/O (database queries, HTTP requests) as part of schema parsing. The predicate returns a Future<bool> and is only executed when safeParseAsync() (or parseAsync()) is called.

Sync vs async parse methods:

MethodReturnsTriggers async refinements
safeParse()ZemaResult<T>No — fails with async_refinement_skipped
safeParseAsync()Future<ZemaResult<T>>Yes
parse()T or throwsNo — throws async_refinement_skipped
parseAsync()Future<T> or throwsYes

Important (breaking change in 0.5.0): Calling safeParse() or parse() on a schema that contains .refineAsync() now returns a ZemaFailure with code async_refinement_skipped (previously, it silently bypassed the predicate and returned a false success). Always use safeParseAsync() or parseAsync() when the chain includes async refinements.

parseInIsolate()

For CPU-intensive validation that has no async refinements, offload to a background isolate to avoid blocking the Flutter UI thread:

// Runs safeParse() in a separate Isolate
final result = await schema.parseInIsolate(largePayload);

Limitation: Only works for schemas composed of built-in primitives. Schemas with user-supplied callbacks (.transform(), .refine(), .preprocess()) capture Dart closures which are not isolate-sendable and will throw at runtime. For those schemas, call safeParse() normally or wrap manually with Isolate.run().

.refineAsync()

Signature:

ZemaSchema<I, O> refineAsync(
  Future<bool> Function(O) predicate, {
  String? message,
  String? code,
})

The predicate receives the validated output of the base schema. Return true to pass, false to fail. On false, a single issue with message (default: 'Async validation failed') and code (default: 'async_custom_error') is produced. If the predicate throws, an async_refinement_error issue is produced instead.

Basic usage

final usernameSchema = z.string().refineAsync(
  (username) async {
    final taken = await db.usernameExists(username);
    return !taken;
  },
  message: 'Username already taken',
);

final result = await usernameSchema.safeParseAsync('alice');

switch (result) {
  case ZemaSuccess(:final value):
    print('Available: $value');
  case ZemaFailure(:final errors):
    print(errors.first.message);
}

Username availability

final usernameSchema = z.string()
    .min(3)
    .max(20)
    .regex(RegExp(r'^[a-zA-Z0-9_]+$'), message: 'Only letters, numbers, and underscores.')
    .refineAsync(
      (username) async {
        final response = await dio.get(
          '/api/users/check-username',
          queryParameters: {'username': username},
        );
        return response.data['available'] as bool;
      },
      message: 'Username already taken',
    );

final result = await usernameSchema.safeParseAsync('alice123');

Email uniqueness

final emailSchema = z.string()
    .email()
    .refineAsync(
      (email) async {
        final response = await dio.post(
          '/api/auth/check-email',
          data: {'email': email},
        );
        return response.data['available'] as bool;
      },
      message: 'Email already registered',
    );

Invite code

final inviteCodeSchema = z.string()
    .length(8)
    .refineAsync(
      (code) async {
        final response = await dio.get('/api/invites/$code');
        final invite = response.data;

        if (invite['used'] == true) return false;

        final expiresAt = DateTime.parse(invite['expiresAt'] as String);
        return DateTime.now().isBefore(expiresAt);
      },
      message: 'Invalid or expired invite code.',
    );

Multiple async refinements

Chain .refineAsync() calls the same way as .refine(). They run sequentially: the first failure stops the chain.

final registrationSchema = z.object({
  'username': z.string()
      .min(3)
      .refineAsync(
        checkUsernameAvailable,
        message: 'Username taken',
      ),
  'email': z.string()
      .email()
      .refineAsync(
        checkEmailAvailable,
        message: 'Email already registered',
      ),
});

final result = await registrationSchema.safeParseAsync({
  'username': 'alice',
  'email': 'alice@example.com',
});

Async cross-field validation

.refineAsync() has no path: parameter. For async validation that needs to attach a path to the issue, perform the check after the schema parse and produce the issue manually:

final orderSchema = z.object({
  'productId': z.string(),
  'quantity':  z.integer().positive(),
});

Future<ZemaResult<Map<String, dynamic>>> validateOrder(
  Map<String, dynamic> data,
) async {
  final result = orderSchema.safeParse(data);
  if (result.isFailure) return result;

  final productId = result.value['productId'] as String;
  final qty = result.value['quantity'] as int;

  final response = await dio.get('/api/products/$productId');
  final stock = response.data['stock'] as int;

  if (qty > stock) {
    return ZemaFailure([
      ZemaIssue(
        code: 'insufficient_stock',
        message: 'Insufficient stock.',
        path: ['quantity'],
      ),
    ]);
  }

  return result;
}

Error handling inside the predicate

If the predicate throws, Zema catches the exception and produces an async_refinement_error issue. Catch errors yourself to control the fallback behavior:

final usernameSchema = z.string().refineAsync(
  (username) async {
    try {
      return await checkUsernameAvailable(username);
    } on TimeoutException {
      return true;   // optimistic on timeout
    } catch (e) {
      logger.error('Username check failed: $e');
      return false;  // pessimistic on unknown error
    }
  },
  message: 'Username validation failed. Please try again.',
);

Timeout

Add .timeout() to the future to avoid hanging indefinitely:

final usernameSchema = z.string().refineAsync(
  (username) => checkUsernameAvailable(username).timeout(
    const Duration(seconds: 5),
    onTimeout: () => true,   // optimistic on timeout
  ),
  message: 'Username already taken',
);

Parallel validation of independent schemas

Each field schema is independent. To validate multiple values concurrently, call safeParseAsync() on each in parallel with Future.wait:

final results = await Future.wait([
  usernameSchema.safeParseAsync(username),
  emailSchema.safeParseAsync(email),
]);

final usernameResult = results[0];
final emailResult = results[1];

Debouncing

Wrap the predicate in a debounce utility for real-time inputs to avoid calling the server on every keystroke:

class AsyncDebouncer {
  final Future<bool> Function(String) validator;
  final Duration delay;
  Timer? _timer;

  AsyncDebouncer({
    required this.validator,
    this.delay = const Duration(milliseconds: 500),
  });

  Future<bool> call(String value) {
    final completer = Completer<bool>();
    _timer?.cancel();
    _timer = Timer(delay, () async {
      completer.complete(await validator(value));
    });
    return completer.future;
  }
}

final debouncer = AsyncDebouncer(validator: checkUsernameAvailable);

final usernameSchema = z.string().refineAsync(
  debouncer.call,
  message: 'Username already taken',
);

Caching

Cache results to avoid redundant network calls for the same value:

final _cache = <String, bool>{};

Future<bool> cachedCheck(String username) async {
  if (_cache.containsKey(username)) return _cache[username]!;

  final result = await checkUsernameAvailable(username);
  _cache[username] = result;
  Future.delayed(const Duration(minutes: 5), () => _cache.remove(username));

  return result;
}

final usernameSchema = z.string().refineAsync(
  cachedCheck,
  message: 'Username already taken',
);

Error codes

CodeTrigger
async_custom_errorPredicate returned false.
async_refinement_errorPredicate threw an exception.
async_refinement_skippedsafeParse() / parse() called on an async schema.
Custom (via code:)Overridden with the code: parameter.

API reference

.refineAsync(predicate, {message, code})

ParameterTypeDefault
predicateFuture<bool> Function(O)required
messageString?'Async validation failed'
codeString?'async_custom_error'

safeParseAsync(value)

Future<ZemaResult<T>> safeParseAsync(I value)

Runs the full schema including async refinements. Never throws.

parseAsync(value)

Future<T> parseAsync(I value)

Runs the full schema including async refinements. Throws ZemaException on failure.

Next steps

  • Refinements: .refine(), .refineAsync(), and .superRefine() full reference
  • Basic Validation: safeParse(), ZemaResult, and result handling
Copyright © 2026