dup 2.0.0 copy "dup: ^2.0.0" to clipboard
dup: ^2.0.0 copied to clipboard

A powerful, flexible schema-based validation library for Dart, inspired by JavaScript's yup.

dup #

A schema-based validation library for Dart, inspired by zod and yup.

Pure Dart — works in Flutter, server, and CLI projects with no platform dependencies.

Features #

  • Fluent, chainable API — build validators with a readable method chain
  • Phase-ordered executionrequired always fires before format and range checks, regardless of chain order
  • Sealed result typesValidationResult and FormValidationResult enable exhaustive switch matching
  • DupSchema — group validators into a form schema with cross-field validation
  • Nested validationValidateObject and ValidateMap flatten errors to dot / bracket notation
  • Conditional ruleswhen() replaces validators based on runtime field values
  • Schema derivationpick(), omit(), and partial() derive new schemas without duplication
  • Three-level error messages — per-call override → global locale → English default
  • Async validator supportaddAsyncValidator for DB lookups and API checks
  • Null-skip semantics — non-required validators pass silently on null

Installation #

dependencies:
  dup: ^2.0.0
import 'package:dup/dup.dart';

Quick Start #

final schema = DupSchema({
  'email': ValidateString().email().required(),
  'age':   ValidateNumber().min(18).required(),
});

final result = await schema.validate({
  'email': 'user@example.com',
  'age':   25,
});

switch (result) {
  case FormValidationSuccess():
    print('All good!');
  case FormValidationFailure():
    for (final field in result.fields) {
      print('$field: ${result(field)!.message}');
    }
}

DupSchema #

Basic usage #

final schema = DupSchema(
  {
    'email':           ValidateString().email().required(),
    'password':        ValidateString().min(8).required(),
    'passwordConfirm': ValidateString().required(),
  },
  labels: {'passwordConfirm': 'Password confirmation'},
);

The labels map overrides the error message label for a field (defaults to the key name).

Reading errors #

if (result is FormValidationFailure) {
  result.hasError('email');       // bool
  result('email')?.message;      // String? — the error message
  result('email')?.code;         // ValidationCode enum value
  result.fields;                  // List<String> — all failing field names
  result.firstField;              // String? — first failing field name
}

Validation methods #

// Async — always safe; required when async validators are registered
final result = await schema.validate(data);

// Sync — throws StateError if async validators are present
final result = schema.validateSync(data);

// Single field — for on-change feedback in TextFormField
final fieldResult = await schema.validateField('email', value);

// Single field with when() rules applied
final fieldResult = await schema.validateField('type', value, data: formData);

// Parallel — runs all field validators concurrently; faster for I/O-bound async validators
final result = await schema.validateParallel(data);

Cross-field validation #

Runs only when all individual fields pass:

schema.crossValidate((data) {
  if (data['password'] != data['passwordConfirm']) {
    return {
      'passwordConfirm': const ValidationFailure(
        code: ValidationCode.custom,
        message: 'Passwords do not match.',
        context: {},
      ),
    };
  }
  return null;
});

Conditional rules — when() #

Replaces validators for specified fields when a condition is met at runtime:

final schema = DupSchema({
  'type':    ValidateString().required(),
  'company': ValidateString(),
}).when(
  field: 'type',
  condition: (v) => v == 'business',
  then: {'company': ValidateString().required()},
);

Schema derivation — pick(), omit(), partial() #

// Keep only listed fields
final loginSchema = fullSchema.pick(['email', 'password']);

// Remove listed fields
final publicSchema = fullSchema.omit(['passwordConfirm']);

// Skip all required checks (useful for PATCH requests)
final patchSchema = fullSchema.partial();

Sharing validator instances across schemas #

Validator objects are used directly — they are not cloned when passed to a schema or when deriving schemas with pick/omit/partial. Each validator's label is set at DupSchema construction time, so sharing the same instance between two schemas with different label overrides will result in the label reflecting whichever schema was constructed last.

// Wrong: same instance in two schemas — label will be 'Email Address' for both
final v = ValidateString().email().required();
final loginSchema = DupSchema({'email': v});
final profileSchema = DupSchema({'email': v}, labels: {'email': 'Email Address'});

// Right: separate instances per schema
final loginSchema = DupSchema({'email': ValidateString().email().required()});
final profileSchema = DupSchema(
  {'email': ValidateString().email().required()},
  labels: {'email': 'Email Address'},
);

Validators #

ValidateString #

Method Phase Description
required() 0 Fails for null or empty string
notBlank() 1 Fails for whitespace-only string
min(n) 2 At least n characters (trimmed)
max(n) 2 At most n characters (trimmed)
matches(RegExp) 1 Must match the regex
email() 1 Valid email address
url() 1 Valid HTTP/HTTPS URL
uuid() 1 Valid UUID v4
password({minLength}) 1 ASCII printable chars, default min length 4
alpha() 1 Letters only (a–z, A–Z)
alphanumeric() 1 Letters and digits only
numeric() 1 Digits only (0–9)
emoji() 1 Fails when value contains emoji
startsWith(prefix) 1 Must start with prefix
endsWith(suffix) 1 Must end with suffix
contains(substring) 1 Must contain substring
ipAddress() 1 Valid IPv4 or IPv6 address
hexColor() 1 Valid hex color (#RGB or #RRGGBB)
base64() 1 Valid Base64-encoded string
json() 1 Valid JSON string
creditCard() 1 Valid credit card number (Luhn check, 13–19 digits)
koMobile({customRegex}) 1 Mobile number (default: Korean format)
koPhone({customRegex}) 1 Landline number (default: Korean format)
bizno({customRegex}) 1 Business registration number (default: Korean)
koPostalCode() 1 Korean 5-digit postal code
equalTo(other) 3 Must equal another value
satisfy(fn) 3 Custom inline predicate
addValidator(fn) 3 Custom validator returning ValidationFailure?
addAsyncValidator(fn) 4 Async custom validator

ValidateNumber #

Method Phase Description
required() 0 Fails for null
min(n) 2 Value ≥ n
max(n) 2 Value ≤ n
between(min, max) 2 Inclusive range
isInteger() 1 No fractional part
isPrecision(digits) 1 At most digits decimal places
isPort() 1 Integer in range 0–65535
isPositive() 2 Value > 0
isNegative() 2 Value < 0
isNonNegative() 2 Value ≥ 0
isNonPositive() 2 Value ≤ 0
isEven() 2 Even integer
isOdd() 2 Odd integer
isMultipleOf(n) 2 Multiple of n

Flutter TextFormField integration:

TextFormField(
  validator: ValidateNumber()
    .setLabel('Age')
    .min(18)
    .max(120)
    .isInteger()
    .toValidator(),
)

toValidator() parses string input to num before validating, returning a parse error for non-numeric input like "abc" or "17세".

ValidateList<T> #

Method Phase Description
required() 0 Fails for null
isNotEmpty() 1 At least one item
isEmpty() 1 Must be empty
minLength(n) 2 At least n items
maxLength(n) 2 At most n items
hasLength(n) 2 Exactly n items
lengthBetween(min, max) 2 Item count in range
contains(item) 2 Must contain item
doesNotContain(item) 2 Must not contain item
containsAll(items) 2 Must contain all items
hasNoDuplicates() 2 No duplicate items
all(predicate) 3 Every item satisfies predicate
any(predicate) 3 At least one item satisfies predicate
none(predicate) 3 No items satisfy predicate
eachItem(fn) 3 Per-item validator; reports first failing index

ValidateBool #

Method Phase Description
required() 0 Fails for null
isTrue() 1 Must be true
isFalse() 1 Must be false
// "Agree to terms" checkbox
ValidateBool().setLabel('Terms').isTrue().required();

ValidateDateTime #

Method Phase Description
required() 0 Fails for null
isBefore(target) 1 Strictly before target
isAfter(target) 1 Strictly after target
isSameDay(target) 1 Same calendar day as target
isToday() 1 Same calendar day as today
isWeekday() 1 Monday–Friday
isWeekend() 1 Saturday or Sunday
min(date) 2 On or after date
max(date) 2 On or before date
between(min, max) 2 Inclusive range
isInFuture() 2 After DateTime.now()
isInPast() 2 Before DateTime.now()
isWithin(duration) 2 Within duration from now

ValidateObject #

Validates a nested Map<String, dynamic> using an inner DupSchema. Errors are flattened with dot notation:

final schema = DupSchema({
  'user': ValidateObject(DupSchema({
    'name':  ValidateString().required(),
    'email': ValidateString().email().required(),
  })).required(),
});

final result = await schema.validate({
  'user': {'name': 'Alice', 'email': 'bad-email'},
});

// Error key: 'user.email'
print(result('user.email')?.message); // user.email is not a valid email address.

ValidateMap<V> #

Validates all keys and values of a Map<String, V>. Errors are flattened with bracket notation:

final schema = DupSchema({
  'scores': ValidateMap<int>()
    .keyValidator(ValidateString().alphanumeric())
    .valueValidator(ValidateNumber().between(0, 100).isInteger())
    .minSize(1)
    .required(),
});

final result = await schema.validate({
  'scores': {'math': 95, 'english': 110},
});

// Error key: 'scores[english]'
print(result('scores[english]')?.message); // english must be at most 100.

Phase Ordering #

Validators always run in phase order, regardless of chain order. required always fires first; async validators always run last.

Phase What runs
0 required
1 notBlank, format checks (email, isInteger, isBefore, …)
2 Constraint checks (min, max, between, …)
3 Custom (addValidator, satisfy, equalTo, …)
4 Async (addAsyncValidator)
// These two chains behave identically
ValidateString().email().required();
ValidateString().required().email();

Error Messages #

Messages are resolved in this order:

  1. messageFactory — argument on the specific method call
  2. ValidatorLocale.current — global locale map keyed by ValidationCode
  3. Hardcoded default — English fallback

Per-call override #

ValidateString()
  .setLabel('Email')
  .email(messageFactory: (label, _) => 'Please enter a valid $label address.');

Global locale #

ValidatorLocale.setLocale(ValidatorLocale({
  ValidationCode.required:     (p) => '${p['name']}은(는) 필수 입력입니다.',
  ValidationCode.emailInvalid: (p) => '${p['name']} 형식이 올바르지 않습니다.',
  ValidationCode.stringMin:    (p) => '${p['name']}은(는) 최소 ${p['min']}자 이상이어야 합니다.',
  ValidationCode.numberMin:    (p) => '${p['name']}은(는) ${p['min']} 이상이어야 합니다.',
}));

Reset in tests to avoid state leakage:

tearDown(() => ValidatorLocale.resetLocale());

Async Validators #

final usernameValidator = ValidateString()
  .setLabel('Username')
  .min(3)
  .max(20)
  .alphanumeric()
  .required()
  ..addAsyncValidator((value) async {
    final taken = await userRepository.exists(value!);
    if (taken) {
      return const ValidationFailure(
        code: ValidationCode.custom,
        message: 'Username is already taken.',
        context: {},
      );
    }
    return null;
  });

final result = await usernameValidator.validateAsync('alice');

Use schema.hasAsyncValidators to decide between validate() and validateSync().


Custom Validators #

Inline predicate #

ValidateString()
  .setLabel('Code')
  .satisfy(
    (v) => v != null && v.startsWith('APP-'),
    messageFactory: (label, _) => '$label must start with APP-.',
  );

Full validator function #

ValidateNumber()
  .setLabel('Score')
  .addValidator((value) {
    if (value != null && value % 7 != 0) {
      return const ValidationFailure(
        code: ValidationCode.custom,
        message: 'Score must be a multiple of 7.',
        context: {},
      );
    }
    return null;
  });

ValidationResult #

Every single-field validator returns a sealed ValidationResult:

final result = ValidateString().email().validate(input);

switch (result) {
  case ValidationSuccess():
    // passed
  case ValidationFailure(:final code, :final message, :final context):
    // code    — ValidationCode enum value
    // message — resolved error string
    // context — parameters used to build the message (e.g. {'name': 'Email', 'min': 8})
}

Migrating from v1 #

See MIGRATING.md for the full before/after guide.

Breaking changes at a glance #

Area v1 v2
Schema class BaseValidatorSchema DupSchema
Validation call useUiForm.validate(schema, data) schema.validate(data)
Validation result throws FormValidationException returns FormValidationResult
Custom validator return String? ValidationFailure?
Locale constructor named params + plain string keys Map<ValidationCode, fn>
ValidateNumber.moreThan(n) removed use min(n)
ValidateNumber.lessThan(n) removed use max(n)
ValidateString.mobile() renamed koMobile()
ValidateString.phone() renamed koPhone()

License #

MIT

3
likes
160
points
127
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A powerful, flexible schema-based validation library for Dart, inspired by JavaScript's yup.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

meta

More

Packages that depend on dup