Custom Types
z.custom<T>() creates a schema backed by an arbitrary boolean predicate. Use it for lightweight one-off rules that don't fit any built-in constraint.
Signature:
ZemaSchema<T, T> custom<T>(bool Function(T) validator, { String? message})
The validator receives a value already typed as T and returns true if valid. On false, a single custom_validation_failed issue is produced with message (or the default 'Custom validation failed').
Basic usage
// Palindrome
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
// Even number
final evenNumber = z.custom<int>(
(n) => n % 2 == 0,
message: 'Must be an even number',
);
evenNumber.parse(42); // 42
evenNumber.parse(43); // throws ZemaException: custom_validation_failed
Type parameter
T constrains both input and output. The validator receives T directly — no runtime type checks needed.
// ✓ Typed — validator receives int
z.custom<int>((n) => n > 0, message: 'Must be positive')
// ✓ Typed — validator receives String
z.custom<String>((s) => s.startsWith('https'), message: 'Must be HTTPS')
With other schemas
z.custom() is most useful composed after a typed schema:
// Validate a string is a valid IPv4 address
final ipv4Schema = z.string()
.regex(RegExp(r'^\d{1,3}(\.\d{1,3}){3}$'))
.transform((s) => s)
.pipe(z.custom<String>((s) {
return s.split('.').every((part) {
final n = int.tryParse(part);
return n != null && n >= 0 && n <= 255;
});
}, message: 'Each octet must be 0–255'));
ipv4Schema.parse('192.168.1.1'); // '192.168.1.1'
ipv4Schema.parse('256.1.1.1'); // throws ZemaException
Common patterns
Slug
final slugSchema = z.custom<String>(
(s) => RegExp(r'^[a-z0-9]+(?:-[a-z0-9]+)*$').hasMatch(s),
message: 'Must be lowercase letters, numbers, and hyphens only.',
);
slugSchema.parse('my-blog-post'); // 'my-blog-post'
slugSchema.parse('My Blog Post'); // throws ZemaException
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.')
.pipe(z.custom<String>(_luhn, message: 'Invalid card number.'));
cardSchema.parse('4532015112830366'); // valid Luhn
cardSchema.parse('1234567890123456'); // throws ZemaException
Minimum age
ZemaSchema<DateTime> minimumAge(int years) => z.custom<DateTime>(
(date) {
final age = DateTime.now().difference(date).inDays ~/ 365;
return age >= years;
},
message: 'Must be at least $years years old.',
);
final adultSchema = minimumAge(18);
adultSchema.parse(DateTime(2000, 1, 1)); // valid
adultSchema.parse(DateTime(2015, 1, 1)); // throws ZemaException
Schema factory
ZemaSchema<String> oneOf(List<String> values) => z.custom<String>(
(s) => values.contains(s),
message: 'Must be one of: ${values.join(', ')}',
);
final statusSchema = oneOf(['pending', 'active', 'archived']);
statusSchema.parse('active'); // 'active'
statusSchema.parse('unknown'); // throws ZemaException
For this specific pattern, z.string().oneOf([...]) is shorter and produces a richer invalid_enum error.
z.custom() vs .refine()
z.custom() is for standalone predicates. .refine() is for adding rules to an existing schema.
z.custom<T>(fn) | schema.refine(fn) | |
|---|---|---|
| Starting point | Standalone schema | Extends an existing schema |
| Validator input | T | Validated Output of the base schema |
| Multiple issues | No — one fixed message | No — one message, but chainable |
| Async | No | .refineAsync() available |
| Complex logic | Use .superRefine() | Use .superRefine() |
// z.custom() — standalone, predicate on T
final palindrome = z.custom<String>(
(s) => s == s.split('').reversed.join(),
message: 'Must be a palindrome',
);
// .refine() — adds a rule to an existing schema
final schema = z.string().min(3).refine(
(s) => s == s.split('').reversed.join(),
message: 'Must be a palindrome',
);
Use .refine() when you want to keep the existing schema's type, constraints, and error codes intact.
Error code
| Code | Trigger |
|---|---|
custom_validation_failed | Validator returned false. |
API reference
ZemaSchema<T, T> custom<T>( bool Function(T) validator, { String? message})
| Parameter | Type | Description |
|---|---|---|
validator | bool Function(T) | Returns true if valid, false to fail. |
message | String? | Error message on failure. Defaults to 'Custom validation failed'. |
Next steps
- Refinements: add rules to an existing schema
- Transforms: map the validated output to a new type