Refinements
Refinements attach custom validation logic on top of any existing schema. They run after the base schema succeeds, a failing base schema skips refinements entirely.
Choosing the right method:
| Method | Async | Severity | Issues | Best for |
|---|---|---|---|---|
.refine() | No | error | 1 | Simple blocking predicates |
.refineAsync() | Yes | error | 1 | I/O checks (DB, network) |
.refineWarn() | No | warning | 1 | Advisory checks that don't block |
.superRefine() | No | any | N | Multi-issue logic, custom paths |
.refine()
Signature:
ZemaSchema<I, O> refine(
bool Function(O) predicate, {
String? message,
String? code,
})
Returns true to pass, false to fail. On failure, one issue is produced with message (default: 'Custom validation failed') and code (default: 'custom_error').
Basic usage
// Even number
final evenSchema = z.integer().refine(
(n) => n % 2 == 0,
message: 'Must be an even number',
);
evenSchema.parse(42); // 42
evenSchema.parse(43); // throws ZemaException: custom_error
// String contains at least one digit
final schema = z.string().min(8).refine(
(s) => s.contains(RegExp(r'[0-9]')),
message: 'Must contain at least one digit.',
code: 'missing_digit',
);
Cross-field validation
final rangeSchema = z.object({
'min': z.integer(),
'max': z.integer(),
}).refine(
(data) => (data['min'] as int) < (data['max'] as int),
message: 'min must be less than max',
);
rangeSchema.parse({'min': 5, 'max': 10}); // valid
rangeSchema.parse({'min': 10, 'max': 5}); // throws ZemaException
Chained refinements — sequential, not exhaustive
Chained .refine() calls run in sequence. The first failure stops the chain — subsequent refinements are skipped.
final schema = z.string()
.refine((s) => s.length >= 8, message: 'Min 8 characters.')
.refine((s) => s.contains(RegExp(r'[A-Z]')), message: 'Needs uppercase.')
.refine((s) => s.contains(RegExp(r'[0-9]')), message: 'Needs digit.');
schema.parse('ab'); // throws ZemaException: 'Min 8 characters.' only
// remaining refinements never run
To collect all failures in a single pass, use .superRefine().
Domain whitelist
final corporateEmailSchema = z.string()
.email()
.refine(
(email) => ['company.com', 'partner.com'].contains(email.split('@').last),
message: 'Must use a corporate email address.',
);
Postal code per country
final shippingSchema = z.object({
'country': z.string(),
'postalCode': z.string(),
}).refine(
(data) => switch (data['country']) {
'US' => RegExp(r'^\d{5}(-\d{4})?$').hasMatch(data['postalCode'] as String),
'CA' => RegExp(r'^[A-Z]\d[A-Z] \d[A-Z]\d$').hasMatch(data['postalCode'] as String),
_ => true,
},
message: 'Invalid postal code format for the selected country.',
);
.refineWarn()
Signature:
ZemaSchema<I, O> refineWarn(
bool Function(O) predicate, {
String? message,
String? code,
})
Unlike .refine(), a failing .refineWarn() predicate does not fail the parse. The parse still returns ZemaSuccess, but the warning issue is surfaced via ZemaResult.warnings. Use this for advisory checks in Flutter forms: e.g. password strength hints, deprecated value notices, or soft data-quality rules.
final passwordSchema = z.string()
.min(8)
.refineWarn(
(s) => s.contains(RegExp(r'[A-Z]')),
message: 'Adding uppercase letters improves password strength.',
code: 'weak_password',
)
.refineWarn(
(s) => s.contains(RegExp(r'[!@#$%^&*]')),
message: 'Special characters make your password harder to crack.',
code: 'no_special_char',
);
final result = passwordSchema.safeParse('hello123');
print(result.isSuccess); // true — parse did not fail
print(result.hasWarnings); // true
print(result.warnings.length); // 2
print(result.warnings.first.code); // 'weak_password'
Warning issues have severity == ZemaSeverity.warning. You can check this field when rendering different UI treatments for errors vs warnings.
.refineAsync()
Note (0.5.0): Calling
safeParse()on a schema with.refineAsync()now returns aZemaFailurewith codeasync_refinement_skippedinstead of silently bypassing the predicate. Always usesafeParseAsync().
Signature:
ZemaSchema<I, O> refineAsync(
Future<bool> Function(O) predicate, {
String? message,
String? code,
})
The predicate runs only when safeParseAsync() (or parseAsync()) is called. Synchronous safeParse() skips it entirely and delegates to the base schema.
If the predicate throws, an async_refinement_error issue is produced.
final uniqueEmailSchema = z.string()
.email()
.refineAsync(
(email) async => !(await db.emailExists(email)),
message: 'This email is already registered.',
code: 'email_taken',
);
// Must use the async path to trigger the check
final result = await uniqueEmailSchema.safeParseAsync(input);
.superRefine()
Signature:
ZemaSchema<I, O> superRefine(
List<ZemaIssue>? Function(O, ValidationContext) validator,
)
The validator receives the validated output and a ValidationContext. Return a list of ZemaIssue objects to report failures, or null (or an empty list) to pass.
Use superRefine when you need:
- Multiple issues in a single validation pass
- Custom
pathon issues (e.g., pointing to the specific field that failed) - Custom
code,meta, orreceivedValueper issue
Password confirmation with path
final registrationSchema = z.object({
'password': z.string().min(8),
'confirmPassword': z.string(),
}).superRefine((data, ctx) {
if (data['password'] != data['confirmPassword']) {
return [
ZemaIssue(
code: 'passwords_mismatch',
message: 'Passwords do not match.',
path: ['confirmPassword'],
),
];
}
return null;
});
Multiple issues in one pass
final passwordSchema = z.string().superRefine((s, ctx) {
final issues = <ZemaIssue>[];
if (s.length < 8) {
issues.add(ZemaIssue(code: 'too_short', message: 'At least 8 characters.'));
}
if (!s.contains(RegExp(r'[A-Z]'))) {
issues.add(ZemaIssue(code: 'missing_uppercase', message: 'Needs an uppercase letter.'));
}
if (!s.contains(RegExp(r'[0-9]'))) {
issues.add(ZemaIssue(code: 'missing_digit', message: 'Needs a digit.'));
}
if (!s.contains(RegExp(r'[!@#$%^&*]'))) {
issues.add(ZemaIssue(code: 'missing_special', message: 'Needs a special character.'));
}
return issues.isEmpty ? null : issues;
});
final result = passwordSchema.safeParse('weak');
// ZemaFailure with 4 issues simultaneously
Inventory constraint
final inventorySchema = z.object({
'quantity': z.integer().gte(0),
'reserved': z.integer().gte(0),
}).superRefine((data, ctx) {
final qty = data['quantity'] as int;
final res = data['reserved'] as int;
if (res > qty) {
return [
ZemaIssue(
code: 'over_reserved',
message: 'Reserved ($res) cannot exceed quantity ($qty).',
path: ['reserved'],
),
];
}
return null;
});
Budget constraint
final orderSchema = z.object({
'items': z.array(z.object({
'price': z.double().positive(),
'quantity': z.integer().positive(),
})),
'maxBudget': z.double().positive(),
}).superRefine((data, ctx) {
final items = data['items'] as List;
final total = items.fold<double>(
0,
(sum, item) => sum + (item['price'] as double) * (item['quantity'] as int),
);
if (total > (data['maxBudget'] as double)) {
return [
ZemaIssue(
code: 'over_budget',
message: 'Order total $total exceeds budget ${data['maxBudget']}.',
path: ['items'],
),
];
}
return null;
});
Real-world patterns
File upload
final fileSchema = z.object({
'filename': z.string(),
'size': z.integer().positive(),
'mimeType': z.string(),
}).superRefine((data, ctx) {
final issues = <ZemaIssue>[];
const maxSize = 10 * 1024 * 1024; // 10 MB
if ((data['size'] as int) > maxSize) {
issues.add(ZemaIssue(
code: 'file_too_large',
message: 'File must be under 10 MB.',
path: ['size'],
));
}
const allowed = ['image/jpeg', 'image/png', 'application/pdf'];
if (!allowed.contains(data['mimeType'])) {
issues.add(ZemaIssue(
code: 'unsupported_type',
message: 'Only JPEG, PNG, and PDF are accepted.',
path: ['mimeType'],
));
}
return issues.isEmpty ? null : issues;
});
Date range
final dateRangeSchema = z.object({
'startDate': z.dateTime(),
'endDate': z.dateTime(),
}).superRefine((data, ctx) {
final start = data['startDate'] as DateTime;
final end = data['endDate'] as DateTime;
if (!end.isAfter(start)) {
return [
ZemaIssue(
code: 'invalid_range',
message: 'End date must be after start date.',
path: ['endDate'],
),
];
}
return null;
});
Error codes
| Code | Method | Trigger |
|---|---|---|
custom_error | .refine() | Predicate returned false. |
async_custom_error | .refineAsync() | Predicate returned false. |
async_refinement_error | .refineAsync() | Predicate threw an exception. |
async_refinement_skipped | .refineAsync() | safeParse() called instead of safeParseAsync(). |
custom_warning | .refineWarn() | Predicate returned false (warning, parse succeeds). |
Custom (via code:) | .refine() / .refineAsync() / .refineWarn() | Overridden with code:. |
Custom (via ZemaIssue) | .superRefine() | Any code you set. |
API reference
.refine(predicate, {message, code})
| Parameter | Type | Default |
|---|---|---|
predicate | bool Function(O) | required |
message | String? | 'Custom validation failed' |
code | String? | 'custom_error' |
.refineAsync(predicate, {message, code})
| Parameter | Type | Default |
|---|---|---|
predicate | Future<bool> Function(O) | required |
message | String? | 'Async validation failed' |
code | String? | 'async_custom_error' |
.refineWarn(predicate, {message, code})
| Parameter | Type | Default |
|---|---|---|
predicate | bool Function(O) | required |
message | String? | 'Validation warning' |
code | String? | 'custom_warning' |
Returns a ZemaSuccess even when the predicate fails. The warning issue is in result.warnings.
.superRefine(validator)
| Parameter | Type |
|---|---|
validator | List<ZemaIssue>? Function(O, ValidationContext) |
Return null or [] to pass. Return a non-empty list to fail with those issues.
Next steps
- Custom Types: standalone predicate schemas with
z.custom() - Transforms: map validated output to a new type