Zema Logo
Validations

Custom Validators

Patterns for reusable, composable, and domain-specific validation logic using .refine(), .superRefine(), and z.custom().

Zema provides three building blocks for custom validation logic:

ToolUse 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

Copyright © 2026