Async Validation
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:
| Method | Returns | Triggers async refinements |
|---|---|---|
safeParse() | ZemaResult<T> | No — fails with async_refinement_skipped |
safeParseAsync() | Future<ZemaResult<T>> | Yes |
parse() | T or throws | No — throws async_refinement_skipped |
parseAsync() | Future<T> or throws | Yes |
Important (breaking change in 0.5.0): Calling
safeParse()orparse()on a schema that contains.refineAsync()now returns aZemaFailurewith codeasync_refinement_skipped(previously, it silently bypassed the predicate and returned a false success). Always usesafeParseAsync()orparseAsync()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, callsafeParse()normally or wrap manually withIsolate.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
| Code | Trigger |
|---|---|
async_custom_error | Predicate returned false. |
async_refinement_error | Predicate threw an exception. |
async_refinement_skipped | safeParse() / parse() called on an async schema. |
Custom (via code:) | Overridden with the code: parameter. |
API reference
.refineAsync(predicate, {message, code})
| Parameter | Type | Default |
|---|---|---|
predicate | Future<bool> Function(O) | required |
message | String? | 'Async validation failed' |
code | String? | '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