Zema Logo
Schemas

Custom Types

Create lightweight predicate schemas with z.custom() for arbitrary single-rule validation.

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 pointStandalone schemaExtends an existing schema
Validator inputTValidated Output of the base schema
Multiple issuesNo — one fixed messageNo — one message, but chainable
AsyncNo.refineAsync() available
Complex logicUse .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

CodeTrigger
custom_validation_failedValidator returned false.

API reference

ZemaSchema<T, T> custom<T>( bool Function(T) validator, { String? message})
ParameterTypeDescription
validatorbool Function(T)Returns true if valid, false to fail.
messageString?Error message on failure. Defaults to 'Custom validation failed'.

Next steps

Copyright © 2026