Zema Logo
Schemas

Refinements

Add custom validation rules on top of any schema with .refine(), .refineAsync(), and .superRefine().

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:

MethodAsyncSeverityIssuesBest for
.refine()Noerror1Simple blocking predicates
.refineAsync()Yeserror1I/O checks (DB, network)
.refineWarn()Nowarning1Advisory checks that don't block
.superRefine()NoanyNMulti-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 a ZemaFailure with code async_refinement_skipped instead of silently bypassing the predicate. Always use safeParseAsync().

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 path on issues (e.g., pointing to the specific field that failed)
  • Custom code, meta, or receivedValue per 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

CodeMethodTrigger
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})

ParameterTypeDefault
predicatebool Function(O)required
messageString?'Custom validation failed'
codeString?'custom_error'

.refineAsync(predicate, {message, code})

ParameterTypeDefault
predicateFuture<bool> Function(O)required
messageString?'Async validation failed'
codeString?'async_custom_error'

.refineWarn(predicate, {message, code})

ParameterTypeDefault
predicatebool Function(O)required
messageString?'Validation warning'
codeString?'custom_warning'

Returns a ZemaSuccess even when the predicate fails. The warning issue is in result.warnings.


.superRefine(validator)

ParameterType
validatorList<ZemaIssue>? Function(O, ValidationContext)

Return null or [] to pass. Return a non-empty list to fail with those issues.


Next steps

Copyright © 2026