Custom Validators
Zema provides three building blocks for custom validation logic:
| Tool | Use when |
|---|---|
.refine(fn) | One extra rule on an existing schema |
.superRefine(fn) | Multiple issues, custom paths, or cross-field logic |
z.custom<T>(fn) | Standalone predicate schema not tied to an existing schema |
See Refinements and Custom Types for the full API reference. This page focuses on reusable patterns.
Validator functions
The simplest pattern: a plain function used as a .refine() predicate.
bool isValidUsername(String s) {
if (s.length < 3 || s.length > 20) return false;
return RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(s);
}
final usernameSchema = z.string().refine(
isValidUsername,
message: 'Username must be 3-20 characters: letters, numbers, and underscores only.',
);
usernameSchema.parse('alice_42'); // 'alice_42'
usernameSchema.parse('a!'); // throws ZemaException
Validator factories
Return a configured schema from a function to make the constraints parameterizable:
ZemaSchema<String> usernameSchema({int min = 3, int max = 20}) =>
z.string()
.min(min)
.max(max)
.regex(
RegExp(r'^[a-zA-Z0-9_]+$'),
message: 'Only letters, numbers, and underscores.',
);
final strictSchema = usernameSchema(min: 5, max: 15);
Multi-rule validation with .superRefine()
Use .superRefine() when you need all failures at once rather than stopping at the first:
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'[a-z]'))) {
issues.add(ZemaIssue(code: 'missing_lowercase', message: 'Needs a lowercase 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 up to 5 issues simultaneously
Validator classes
Encapsulate rules and configuration in a class. Expose a schema getter that wires the logic into Zema:
class PasswordValidator {
final int minLength;
final bool requireSpecial;
PasswordValidator({this.minLength = 8, this.requireSpecial = false});
ZemaSchema<String> get schema => z.string().superRefine((s, ctx) {
final issues = <ZemaIssue>[];
if (s.length < minLength) {
issues.add(ZemaIssue(
code: 'too_short',
message: 'At least $minLength 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 (requireSpecial && !s.contains(RegExp(r'[!@#$%^&*]'))) {
issues.add(ZemaIssue(code: 'missing_special', message: 'Needs a special character.'));
}
return issues.isEmpty ? null : issues;
});
}
final strict = PasswordValidator(minLength: 12, requireSpecial: true);
final result = strict.schema.safeParse('WeakPass123');
// ZemaFailure: too_short, missing_special
Domain-specific validators
Corporate email
ZemaSchema<String> corporateEmail(List<String> allowedDomains) =>
z.string().email().refine(
(email) => allowedDomains.contains(email.split('@').last.toLowerCase()),
message: 'Must use a corporate email address.',
code: 'invalid_domain',
);
final schema = corporateEmail(['company.com', 'company.co.uk']);
schema.parse('alice@company.com'); // 'alice@company.com'
schema.parse('alice@gmail.com'); // throws ZemaException: invalid_domain
Luhn (credit card)
bool _luhn(String digits) {
var sum = 0;
var odd = true;
for (var i = digits.length - 1; i >= 0; i--) {
var d = int.parse(digits[i]);
if (odd) { d *= 2; if (d > 9) d -= 9; }
sum += d;
odd = !odd;
}
return sum % 10 == 0;
}
final cardSchema = z.string()
.regex(RegExp(r'^\d{13,19}$'), message: 'Must be 13-19 digits.')
.refine(_luhn, message: 'Invalid card number.')
.transform((s) => s.replaceAll(RegExp(r'\D'), ''));
cardSchema.parse('4532015112830366'); // '4532015112830366' (digits only)
cardSchema.parse('1234567890123456'); // throws ZemaException
Conditional field requirement
When a field is required only under a certain condition, use .superRefine() on the parent object to attach the issue to the right path:
final userSchema = z.object({
'role': z.string().oneOf(['user', 'admin']),
'adminKey': z.string().optional(),
}).superRefine((data, ctx) {
if (data['role'] == 'admin' && (data['adminKey'] == null || (data['adminKey'] as String).isEmpty)) {
return [
ZemaIssue(
code: 'required',
message: 'Admin key is required for the admin role.',
path: ['adminKey'],
),
];
}
return null;
});
userSchema.parse({'role': 'admin', 'adminKey': ''});
// throws ZemaException: required (path: adminKey)
Standalone predicate schemas with z.custom<T>()
Use z.custom<T>() for a self-contained schema backed by a single boolean predicate. The validator receives a value already typed as T — no runtime type checks needed.
// z.custom<T> — standalone, one fixed message
final palindrome = z.custom<String>(
(s) => s == s.split('').reversed.join(),
message: 'Must be a palindrome.',
);
palindrome.parse('racecar'); // 'racecar'
palindrome.parse('hello'); // throws ZemaException: custom_validation_failed
Compose z.custom<T>() after a typed schema via .pipe():
final strictEmail = z.string()
.email()
.transform((s) => s)
.pipe(z.custom<String>(
(s) => ['company.com', 'partner.com'].contains(s.split('@').last),
message: 'Must use an allowed domain.',
));
Testing custom validators
Use safeParse() in tests, parse() throws and cannot be matched on isSuccess:
void main() {
final schema = PasswordValidator(minLength: 8, requireSpecial: true).schema;
test('accepts strong password', () {
final result = schema.safeParse('StrongPass1!');
expect(result.isSuccess, true);
});
test('rejects short password', () {
final result = schema.safeParse('Weak1!');
expect(result.isFailure, true);
expect(result.errors.any((e) => e.code == 'too_short'), true);
});
test('rejects password missing special character', () {
final result = schema.safeParse('StrongPass123');
expect(result.isFailure, true);
expect(result.errors.any((e) => e.code == 'missing_special'), true);
});
}
Next steps
- Refinements:
.refine(),.refineAsync(), and.superRefine()full reference - Custom Types:
z.custom<T>()standalone predicate schemas - Async Validation: validators that require I/O