validart 1.3.0 copy "validart: ^1.3.0" to clipboard
validart: ^1.3.0 copied to clipboard

A type-safe validation library for Dart, inspired by Zod. Supports parse/safeParse, transforms, coercion, schema composition, and structured errors.

Validart #

pub package package publisher

A type-safe validation library for Dart, inspired by Zod.

Built for chaining, schema composition, i18n, and extensibility. Includes validators for emails, phone numbers, dates, and more.

Table of Contents #

Installation #

dart pub add validart
import 'package:validart/validart.dart';

Basic Usage #

// No instantiation needed — V is a static class
final schema = V.string().email();

schema.validate('user@example.com'); // true
schema.validate('invalid');          // false

// Get structured errors
final errors = schema.errors('invalid');
// [VError(code: invalid_email, message: Invalid email address)]

// Parse — throws on failure
final value = schema.parse('user@example.com'); // 'user@example.com'

// SafeParse — never throws
final result = schema.safeParse('invalid');
if (result case VFailure(:final errors)) {
  print(errors.first.message);
}

Types #

String #

V.string()
  .notEmpty()
  .min(5)
  .max(100)
  .email();

Available: notEmpty, min, max, length, email, url, uuid, ulid, nanoId, mongoId, ip, pattern, date, time, contains, startsWith, endsWith, equals, alpha, alphanumeric, slug, password, jwt, card, cvv, phone, base64, hexColor, mac, semver, iban, json, integer, numeric, postalCode, taxId, licensePlate.

uuid() accepts RFC 4122 v1–v5 and RFC 9562 v6–v8. Pass version: UuidVersion.vN (enum) to restrict — e.g. V.string().uuid(version: UuidVersion.v7) for timestamp-ordered only.

Pre-processing: trim, toLowerCase, toUpperCase, toPascalCase, toCamelCase, toSnakeCase, toScreamingSnakeCase, toSlug — always run before validation, regardless of chain order.

V.string().toSlug().parse('My Blog Post!');    // 'my-blog-post'
V.string().toCamelCase().parse('hello_world'); // 'helloWorld'

Accents are transliterated by default (São Joãosao-joao). Pass keepAccents: true to preserve them:

V.string().toSlug().parse('São João');                  // 'sao-joao'
V.string().toSlug(keepAccents: true).parse('São João'); // 'são-joão'

Date

Without format, accepts multiple known layouts (ISO extended/basic, BR DD/MM/YYYY, US MM/DD/YYYY, EU DD.MM.YYYY, dashed variants). Calendar-invalid dates are rejected.

V.string().date().validate('2024-01-15'); // true (ISO)
V.string().date().validate('15/01/2024'); // true (BR)
V.string().date().validate('2024-02-30'); // false (calendar-invalid)

// Strict format — tokens: YYYY, MM, DD. Any other char is a literal separator.
V.string().date(format: 'DD/MM/YYYY').validate('15/01/2024'); // true
V.string().date(format: 'DD/MM/YYYY').validate('2024-01-15'); // false

URL

Defaults to http and https. Pass a custom schemes set to accept other protocols:

V.string().url().validate('https://example.com');                       // true
V.string().url().validate('ftp://example.com');                         // false
V.string().url(schemes: {'http', 'https', 'ftp'}).validate('ftp://x');  // true
V.string().url(schemes: {'ws', 'wss'}).validate('wss://x.io');          // WebSocket-only

Password

Default policy: minimum 8 characters, one uppercase, one lowercase, one digit, one special from !@#$%^&*(),.?":{}|<>. Pass specialChars to expand the accepted set:

V.string().password().validate('Str0ng!Pass');                       // true
V.string().password().validate('Str0ng_Pass');                       // false ('_' not in default)
V.string().password(specialChars: r'!@#$%^&*()-_+=<>?')
  .validate('Str0ng_Pass');                                          // true

Numeric strings

integer and numeric validate that the string represents a number, without converting the output type. Use them when the pipeline value must stay a String (form fields, query params). For conversion use V.coerce.int() / V.coerce.double() instead.

V.string().integer().validate('42');   // true
V.string().integer().validate('-42');  // true
V.string().integer().validate('3.14'); // false (decimal)
V.string().integer().validate('0xFF'); // false (hex)

V.string().numeric().validate('3.14');     // true
V.string().numeric().validate('42e3');     // true (scientific)
V.string().numeric().validate('NaN');      // false
V.string().numeric().validate('Infinity'); // false

Card

Without brands, any Luhn-valid number (with or without mask) is accepted. Pass a list of CardBrandPattern to restrict:

V.string().card().validate('4532 0151 1283 0366'); // true

V.string()
  .card(brands: [const VisaBrand(), const MastercardBrand()])
  .validate('4111111111111111'); // true (Visa)

Built-in brands: VisaBrand, MastercardBrand, AmexBrand, DinersBrand, DiscoverBrand, JcbBrand. External packages can extend CardBrandPattern to add more (e.g. EloBrand, HipercardBrand in validart_br).

Pin the input shape with ValidationMode:

V.string().card(mode: ValidationMode.formatted).validate('4532 0151 1283 0366'); // true
V.string().card(mode: ValidationMode.formatted).validate('4532015112830366');     // false
V.string().card(mode: ValidationMode.unformatted).validate('4532015112830366');   // true

Phone

Defaults to E.164 with an optional leading +. Pass a list of PhonePatterns to plug in country-specific rules — validation succeeds when any pattern in the list matches:

V.string().phone().validate('+14155552671'); // true (E.164 default)

class BrPhonePattern extends PhonePattern {
  const BrPhonePattern();

  @override
  String get code => 'invalid_phone_br';

  @override
  Map<String, dynamic>? validate(String value) =>
      RegExp(r'^\+?55\d{10,11}$').hasMatch(value) ? null : {};
}

V.string().phone(patterns: [const BrPhonePattern()]);

// Multi-country — accept either BR or E.164-formatted numbers:
V.string().phone(patterns: [
  const BrPhonePattern(),
  const E164PhonePattern(),
]);

Use CountryCodeFormat to pin whether the leading + is required, optional (default) or none:

V.string().phone(
  patterns: [const E164PhonePattern(countryCode: CountryCodeFormat.required)],
).validate('14155552671'); // false — `+` missing

V.string().phone(
  patterns: [const E164PhonePattern(countryCode: CountryCodeFormat.none)],
).validate('+14155552671'); // false — `+` forbidden

Postal code

Pluggable pattern. Core ships with UsZipPattern, CaPostalCodePattern, UkPostcodePattern. Others (BR CEP, etc.) come from extension packages:

V.string().postalCode(patterns: [const UsZipPattern()]).validate('94103-1234');
V.string().postalCode(patterns: [const CaPostalCodePattern()]).validate('K1A 0B1');
V.string().postalCode(patterns: [const UkPostcodePattern()]).validate('SW1A 1AA');

// Multi-country — accept any of several layouts at once:
V.string().postalCode(patterns: [
  const UsZipPattern(),
  const CaPostalCodePattern(),
  const UkPostcodePattern(),
]).validate('SW1A 1AA'); // true

When multiple patterns are configured, the error's {name} param joins each pattern's name with / — so a single locale template like 'Invalid {name}' renders as Invalid US ZIP / Canadian Postal Code / UK Postcode.

CaPostalCodePattern and UkPostcodePattern accept a mode to require or forbid the separating space:

V.string().postalCode(
  patterns: [const UkPostcodePattern(mode: ValidationMode.formatted)],
).validate('SW1A1AA'); // false — space required

V.string().postalCode(
  patterns: [const CaPostalCodePattern(mode: ValidationMode.unformatted)],
).validate('K1A0B1'); // true

Tax ID

Pluggable pattern. Core ships with UsSsnPattern, UkNiNumberPattern, CaSinPattern (SIN with Luhn check). Country-specific IDs with custom check digits (e.g. BR CPF/CNPJ) come from extension packages:

V.string().taxId(patterns: [const UsSsnPattern()]).validate('123-45-6789');
V.string().taxId(patterns: [const UkNiNumberPattern()]).validate('AB123456C');
V.string().taxId(patterns: [const CaSinPattern()]).validate('130-692-544');

// Multi-country — same input shape, multiple valid options:
V.string().taxId(patterns: [
  const UsSsnPattern(),
  const UkNiNumberPattern(),
  const CaSinPattern(),
]);

All three accept a mode (default ValidationMode.any) to pin formatted vs unformatted input:

V.string().taxId(
  patterns: [const UsSsnPattern(mode: ValidationMode.formatted)],
).validate('123456789'); // false — dashes required

V.string().taxId(
  patterns: [const CaSinPattern(mode: ValidationMode.unformatted)],
).validate('130692544'); // true

Note: UsSsnPattern in ValidationMode.any only accepts fully-formatted (123-45-6789) or fully-unformatted (123456789) input. Mixed shapes like 123-456789 are now rejected.

License plate

Pluggable pattern. Core ships with UkPlatePattern (post-2001 format — stable nationwide). US and Canada plates vary heavily by state/province and are not built in; implement them per your needs or use an extension package:

V.string().licensePlate(patterns: [const UkPlatePattern()]).validate('AB12 CDE');

V.string().licensePlate(
  patterns: [const UkPlatePattern(mode: ValidationMode.unformatted)],
).validate('AB12CDE'); // true

Int #

Validates integer values with range, sign, and divisibility checks. Use for quantities, counts, indices, and IDs that must be whole numbers.

final age = V.int().min(0).max(120);
age.validate(30);   // true
age.validate(-1);   // false
age.validate(121);  // false

V.int().positive().multipleOf(5).validate(15); // true
V.int().prime().validate(7);                   // true
V.int().odd().validate(4);                     // false

Available: min, max, positive, negative, between, multipleOf, even, odd, prime.

Combine with nullable(), defaultValue(), or .array():

V.int().min(1).nullable().validate(null);              // true
V.int().positive().array().min(1).validate([1, 2, 3]); // true

Double #

Validates floating-point numbers — prices, coordinates, weights. Chain finite() to reject NaN / Infinity.

final price = V.double().positive().finite();
price.validate(9.99);             // true
price.validate(double.infinity);  // false

V.double().decimal().validate(3.14); // true  (has fractional part)
V.double().integer().validate(3.0);  // true  (whole number)
V.double().integer().validate(3.14); // false

Available: min, max, positive, negative, between, multipleOf, finite, decimal, integer.

decimal requires a fractional part; integer requires none — the two are complementary filters for a double.

Bool #

Validates boolean values. Use isTrue() for terms-acceptance or opt-in flags, isFalse() for soft-delete toggles or "not suspended" checks.

final accepted = V.bool(message: 'You must accept the terms').isTrue();
accepted.validate(true);  // true
accepted.validate(false); // false
accepted.validate(null);  // false — factory-level `message` fires for null

V.bool().isFalse().validate(false); // true

Available: isTrue, isFalse.

null is rejected by default. Pair with .nullable() when the absence of a value is meaningful.

Date #

Validates DateTime values — deadlines, birth dates, scheduling windows.

final ref = DateTime(2024, 1, 1);

V.date().after(ref).validate(DateTime(2024, 6, 15));  // true
V.date().before(ref).validate(DateTime(2023, 12, 1)); // true
V.date().between(ref, DateTime(2024, 12, 31))
  .validate(DateTime(2024, 6, 15));                   // true

V.date().weekday().validate(DateTime(2024, 1, 15)); // true (Monday)
V.date().weekend().validate(DateTime(2024, 1, 6));  // true (Saturday)

Available: after, before, between, weekday, weekend, age.

age validates the age derived from a birthdate (computed against DateTime.now() at validation time):

V.date().age(min: 18);          // 18 or older
V.date().age(min: 18, max: 65); // between 18 and 65
V.date().age(max: 120);         // sanity check on claimed birthdate

Map (Structured Objects) #

Validates Map<String, dynamic> with a schema:

final userSchema = V.map({
  'name': V.string().min(1),
  'email': V.string().email(),
  'age': V.int().min(0).nullable(),
});

userSchema.validate({'name': 'Alice', 'email': 'a@b.com'}); // true

Errors include field paths:

final errors = userSchema.errors({'name': '', 'email': 'bad'});
// [VError(code: string.too_small, path: [name]), VError(code: invalid_email, path: [email])]

Schema Composition #

final base = V.map({'name': V.string(), 'email': V.string().email()});

base.pick(['name']);                          // only name
base.omit(['email']);                         // everything except email
base.extend({'password': V.string().min(8)}); // add fields
base.merge(otherSchema);                     // combine two schemas
base.partial();                              // all fields nullable
base.strict();                               // reject unknown keys
base.passthrough();                          // allow unknown keys

Cross-Field Validation #

Use equalFields when one field's value must match another — the classic case is password confirmation. Both fields still run their own validators first; equalFields checks equality afterwards and emits map.fields_not_equal.

final signup = V.map({
  'password': V.string().min(8),
  'confirm': V.string(),
}).equalFields('password', 'confirm');

signup.validate({'password': 'secret123', 'confirm': 'secret123'}); // true
signup.validate({'password': 'secret123', 'confirm': 'oops'});      // false

signup.errors({'password': 'secret123', 'confirm': 'oops'})!.first.code;
// map.fields_not_equal

Custom Field Validation #

refineField attaches a custom predicate to a specific field path, so the resulting error lives on that field in the shape — not on the root of the map. Reach for it when a business rule depends on data already present on the map (e.g. "age must be 18+", "end date must be after start date") and the check is too specific for a generic validator.

final adult = V.map({
  'age': V.int(),
}).refineField(
  (data) => (data['age'] as int) >= 18,
  path: 'age',
  message: 'Must be at least 18',
);

adult.validate({'age': 21}); // true
adult.validate({'age': 16}); // false

final err = adult.errors({'age': 16})!.first;
err.path;    // [age]
err.message; // Must be at least 18

Conditional Validation #

when(field, equals: value, then: {...}) applies extra field validators only when another field has a specific value. Ideal for discriminated shapes ("if type is 'company', then cnpj is required") — fields in then are merged over the base schema for matching rows, and skipped entirely otherwise.

final account = V.map({
  'type': V.string(),
  'cnpj': V.string().nullable(),
}).when('type', equals: 'company', then: {
  'cnpj': V.string().min(14),
});

account.validate({'type': 'person',  'cnpj': null});              // true — rule skipped
account.validate({'type': 'company', 'cnpj': '12345678000190'});  // true
account.validate({'type': 'company', 'cnpj': null});              // false — cnpj now required

Multiple when calls can be chained; each is independent.

Array of Maps #

Call .array() on any VMap to validate a list of rows (table-shaped data, CSV imports, JSON arrays of objects). Errors include the row index as the first segment of the path, followed by the field key.

final users = V.map({
  'name': V.string().min(1),
  'email': V.string().email(),
}).array();

users.validate([
  {'name': 'Alice', 'email': 'alice@ex.com'},
  {'name': 'Bob',   'email': 'bob@ex.com'},
]); // true

users.errors([
  {'name': 'Alice', 'email': 'alice@ex.com'},
  {'name': '',     'email': 'not-an-email'},
]);
// [VError(code: string.too_small, path: [1, name]),
//  VError(code: string.email,     path: [1, email])]

Object (Entity Validation) #

Validates class instances (entities, DTOs, domain models) with type-safe field extraction — no runtime casts, no string keys for field access. Pick VObject<T> over VMap when you already have a typed class and want the schema to match its structure exactly.

class User {
  final String name;
  final String email;
  final int age;
  User({required this.name, required this.email, required this.age});
}

final schema = V.object<User>(
  configure: (o) => o
    .field('name', (u) => u.name, V.string().min(1))
    .field('email', (u) => u.email, V.string().email())
    .field('age', (u) => u.age, V.int().min(0).max(120)),
);

schema.validate(User(name: 'Jo', email: 'jo@x.com', age: 30)); // true

Errors include the field name in the path — same convention as VMap:

final errors = schema.errors(User(name: '', email: 'bad', age: 30));
// [VError(code: 'string.too_small', path: ['name']),
//  VError(code: 'string.email', path: ['email'])]

Entity-level rules go via refine():

V.object<User>().refine(
  (u) => u.name.isNotEmpty,
  message: 'Name cannot be empty',
  code: 'empty_name',
);

Array #

Validates List<T> — each element goes through the element schema, then the list is checked against the array-level constraints.

final emails = V.string().email().array().min(1).unique();

emails.validate(['a@b.com', 'c@d.com']); // true
emails.validate(['a@b.com', 'a@b.com']); // false (not unique)
emails.validate([]);                     // false (min 1)

Errors include the array index in the path:

final errors = emails.errors(['a@b.com', 'bad']);
// [VError(code: 'string.email', path: [1])]

Available: min, max, unique, contains.

contains asserts that a set of required values is present (emitted code is array.contains_all):

final roles = V.string().array().contains(['admin']);
roles.validate(['admin', 'user']); // true
roles.validate(['user']);          // false

Nested arrays work via the VArray<T> constructor directly:

final matrix = VArray<List<int>>(V.int().array());
matrix.validate([[1, 2], [3, 4]]); // true

Other Types #

Enum #

Validates that a value is a member of a Dart enum. Use for fixed sets of known states (status, role, category).

enum Status { active, inactive, pending }

final schema = V.enm(Status.values);
schema.validate(Status.active); // true
schema.validate('active');      // false (string, not enum member)
schema.validate(null);          // false

schema.array().validate([Status.active, Status.pending]); // true

Literal #

Validates that a value is exactly a single expected value. Use for discriminator fields, feature flags locked to a specific value, or sentinel checks.

final admin = V.literal('admin');
admin.validate('admin'); // true
admin.validate('user');  // false

// Works with any equatable type:
V.literal(42).validate(42);     // true
V.literal(true).validate(true); // true

For "one of several literals", compose with V.union:

final role = V.union([
  V.literal('admin'),
  V.literal('editor'),
  V.literal('viewer'),
]);
role.validate('editor'); // true
role.validate('guest');  // false

Union #

Validates that a value matches at least one of several schemas. Use when an API accepts multiple input shapes — e.g. an ID that can be a UUID string or an int, or a config field that's either a preset name or a custom value.

final id = V.union([
  V.string().uuid(),
  V.int().positive(),
]);

id.validate('550e8400-e29b-41d4-a716-446655440000'); // true (matches uuid)
id.validate(42);                                     // true (matches int)
id.validate('not-a-uuid');                           // false
id.validate(-1);                                     // false

On failure the emitted code is 'union.invalid', and the per-option failures are available in error.context for diagnostics. error.context is a List<List<VError>> — one list of errors per option, in the same order as declared:

final errors = id.errors(-5);
print(errors!.first.code);   // 'union.invalid'
print(errors.first.context);
// [
//   [VError(code: 'string.invalid_type', ...)],  // option 0: V.string().uuid()
//   [VError(code: 'number.positive', ...)],      // option 1: V.int().positive()
// ]

Coercion #

Converts input types automatically before validation runs. Use when the raw input comes in as a string (query params, form fields, JSON text) but you need the typed value downstream.

V.coerce.int().parse('42');          // 42   (int)
V.coerce.double().parse('3.14');     // 3.14 (double)
V.coerce.string().parse(42);         // '42' (String)
V.coerce.bool().parse('true');       // true (bool)
V.coerce.date().parse('2024-01-15'); // DateTime(2024, 1, 15)

Date coercion accepts ISO 8601 plus 8 regional layouts by defaultYYYY-MM-DD, YYYY/MM/DD, YYYYMMDD, DD/MM/YYYY, MM/DD/YYYY, DD-MM-YYYY, MM-DD-YYYY, DD.MM.YYYY. Calendar-invalid dates like 30/02/2024 still throw. For strict format validation — e.g. rejecting 01/15/2024 in a BR-only pipeline because of DD/MM vs MM/DD ambiguity — chain V.string().date(format: 'DD/MM/YYYY') before the coercion step.

V.coerce.date().parse('2024-01-15'); // DateTime(2024, 1, 15)
V.coerce.date().parse('15/01/2024'); // DateTime(2024, 1, 15)
V.coerce.date().parse('01/02/2024'); // DateTime(2024, 2, 1) — DD/MM wins by list order
V.coerce.date().parse('30/02/2024'); // throws — calendar-invalid

Coercion vs. string validatorsV.coerce.int() converts '42' to 42; V.string().integer() keeps '42' as String but validates that it could be converted. Pick coercion when you need the typed value in the output; pick the string validator when the downstream code still expects a String.

Chain additional validators after coercion:

V.coerce.int().min(1).max(100).parse('50'); // 50
V.coerce.int().min(1).parse('0');           // throws VException

Pipeline #

The validation pipeline runs in three phases:

  1. Pre-processing — normalizes the value before validation (trim, toLowerCase, toUpperCase). The order in the chain does not matter — these always run first.
  2. Validation — checks constraints on the normalized value (email, min, max, etc.).
  3. Post-processing — transforms the validated value (transform<O>()). Only runs if validation passes.
// Both are equivalent — trim always runs before email validation:
V.string().trim().email();
V.string().email().trim();

Transform #

Change the output type (post-processing phase):

final schema = V.string().transform<int>((s) => s.length);
schema.parse('hello'); // 5

Preprocess #

Transform the raw input before type checking — runs before everything else, including coercion and null checks:

final schema = V.string()
  .preprocess((v) => v?.toString().trim() ?? '');

schema.parse(42); // '42'

Modifiers #

Available on all types:

V.string().nullable();          // allows null
V.string().defaultValue('N/A'); // uses default when null
V.string().refine(             // custom validation
  (v) => v.contains('@'),
  message: 'Must contain @',
);

The default is validated by the rest of the pipeline — if it doesn't satisfy the schema, parsing fails:

V.string().defaultValue('').min(3).parse(null); // throws VException
V.string().defaultValue('hello').min(3).parse(null); // 'hello'

Custom required message per schema #

Every factory (V.string(), V.int(), V.bool(), V.date(), V.map(), V.array(), V.object(), V.enm(), V.literal(), V.union()) accepts an optional message that customizes the required error (fired on null input) without touching the locale:

V.bool(message: 'You must accept the terms').isTrue();
V.string(message: 'Name is required').min(1);
V.int(message: 'Age is required').between(0, 150);

Fallback chain for the required error: factory message (per schema) → locale translation → default English.

Individual validators (.email(message: ...), .min(n, message: ...), .refine(fn, message: ...), ...) accept their own message for the error they produce. The factory-level one fires only on null input; the validator-level one fires only when that validator rejects a non-null value — both can coexist on the same schema:

V.string(message: 'Name is required')
    .min(3, message: (n) => 'At least $n chars');

Async Validation #

For checks that need IO (uniqueness in a database, remote token verification), use refineAsync:

final schema = V.string().email().refineAsync(
  (email) async => !(await db.emailExists(email)),
  message: 'Email already registered',
  code: 'email_taken',
);

await schema.validateAsync('a@b.com');   // Future<bool>
await schema.parseAsync('a@b.com');      // Future<String?>
await schema.safeParseAsync('a@b.com');  // Future<VResult<String?>>
await schema.errorsAsync('a@b.com');     // Future<List<VError>?>

Any schema containing refineAsync becomes async-only — calling the sync consumers (validate, parse, etc.) throws VAsyncRequiredException, pointing to the *Async variant. Schemas without refineAsync stay fully sync with zero overhead. Async propagates through VMap, VArray, VObject, VUnion, and VTransformed.

More async primitives #

// Timeout on a slow check — exceeding counts as failure
V.string().refineAsync(
  check,
  timeout: const Duration(seconds: 5),
);

// Custom AsyncValidator for reuse across schemas
class UsernameAvailable extends AsyncValidator<String> {
  const UsernameAvailable();
  @override String get code => 'username_taken';
  @override Future<Map<String, dynamic>?> validate(String value) async =>
      (await db.usernameExists(value)) ? {} : null;
}

V.string().addAsync(const UsernameAvailable());

// Async pre-processing (before type check)
V.string().preprocessAsync((raw) async => await api.resolveAlias(raw as String));

// Async post-processing (change output type)
final loader = V.string().uuid().transformAsync<User>(
  (id) async => await db.loadUser(id),
);
final user = await loader.parseAsync('550e8400-...'); // User

Form Errors #

Convert errors to a map for Flutter forms:

final result = schema.safeParse(data);

if (result case VFailure(:final errors)) {
  final map = result.toMap();
  // {'email': 'Invalid email address', 'name': 'Required'}
}

i18n (Internationalization) #

Set translations using VLocale:

V.setLocale(const VLocale({
  'required': 'Campo obrigatório',
  'invalid_email': 'Email inválido',
  'string.too_small': 'Mínimo de {min} caracteres',
}));

Only override what you need — everything else falls back to English defaults. Switch locale at runtime:

V.setLocale(const VLocale(ptBrTranslations));
V.setLocale(const VLocale(esTranslations));
V.setLocale(const VLocale()); // reset to English

Type-specific overrides #

Every schema emits a type-prefixed error code for required and invalid_typeVStringstring.required, VIntint.required, etc. This lets you translate each type independently while still having a single generic fallback. Keys can be flat or nested:

V.setLocale(const VLocale({
  // Generic fallback — applies to every schema that has no type-specific override.
  'required': 'Campo obrigatório',

  // Nested override — only strings.
  'string': {
    'required': 'Campo de texto obrigatório',
  },

  // Flat override — same idea, written inline. Works interchangeably.
  'int.required': 'Número obrigatório',
}));

V.string().errors(null)!.first.message; // 'Campo de texto obrigatório'
V.int().errors(null)!.first.message;    // 'Número obrigatório'
V.bool().errors(null)!.first.message;   // 'Campo obrigatório' (generic fallback)

Lookup order for any prefixed code (e.g. string.required): custom prefixed → custom generic (required) → default prefixed → default generic → the code itself.

Per-validator override #

Bypasses the locale entirely for a single validator call:

V.string().min(3, message: (n) => 'At least $n chars');

Manual translation #

Use V.t() to resolve a code yourself (useful for custom validators or UI):

V.t('string.too_small', {'min': 3}); // 'Mínimo de 3 caracteres'

Error codes #

Error codes are organized into one sealed class per type. VCode itself holds only the generic fallbacks (required, invalidType, custom); everything else lives in a companion class — VStringCode, VNumberCode, VIntCode, VDoubleCode, VBoolCode, VDateCode, VArrayCode, VMapCode, VObjectCode, VEnumCode, VLiteralCode, VUnionCode:

All emitted codes follow the <type>.<action> convention — the three generic fallbacks stay flat in VCode only as a backstop for VLocale:

VCode.required            // 'required'  (generic fallback key)
VCode.invalidType         // 'invalid_type'
VCode.custom              // 'custom'

VStringCode.required      // 'string.required' (emitted by VString)
VStringCode.invalidType   // 'string.invalid_type'
VStringCode.email         // 'string.email'
VStringCode.tooSmall      // 'string.too_small'
VStringCode.integer       // 'string.integer'
VStringCode.postalCode    // 'string.postal_code'

VIntCode.required         // 'int.required'
VIntCode.even             // 'int.even'
VIntCode.prime            // 'int.prime'

VDoubleCode.required      // 'double.required'
VDoubleCode.integer       // 'double.integer' (double that is a whole number)
VDoubleCode.decimal       // 'double.decimal'

VNumberCode.positive      // 'number.positive' (shared by VInt and VDouble)
VNumberCode.tooSmall      // 'number.too_small'

VBoolCode.isTrue          // 'bool.is_true'
VDateCode.tooSmall        // 'date.too_small'
VDateCode.weekday         // 'date.weekday'
VArrayCode.unique         // 'array.unique'
VMapCode.unrecognizedKey  // 'map.unrecognized_key'
VEnumCode.invalid         // 'enum.invalid'
VLiteralCode.invalid      // 'literal.invalid'
VUnionCode.invalid        // 'union.invalid'

Each sealed class is implicitly abstract and cannot be extended outside the library — they serve purely as namespaces for the static const codes they expose.

Complete translation template #

Every translatable key in one place. Copy, replace the values with your language, and pass to V.setLocale. Keys you omit fall back to English defaults — for type-prefixed codes (string.required, int.required, …), an omitted key also falls back to the generic sibling (required).

Two equivalent formats are supported — pick the one you prefer, or mix them.

Flat format

Same groups as the nested format, just spelled out with the <type>.<action> prefix on each key.

V.setLocale(const VLocale({
  'required': 'Required',
  'invalid_type': 'Expected {expected}, received {received}',
  'custom': 'Invalid value',

  'string.required': 'Required',
  'string.invalid_type': 'Expected {expected}, received {received}',
  'string.not_empty': 'Must not be empty',
  'string.too_small': 'Must be at least {min} characters',
  'string.too_big': 'Must be at most {max} characters',
  'string.length': 'Must be exactly {length} characters',
  'string.integer': 'Must be a valid integer',
  'string.numeric': 'Must be a valid number',
  'string.email': 'Invalid email address',
  'string.url': 'Invalid URL',
  'string.uuid': 'Invalid UUID',
  'string.ip': 'Invalid IP address',
  'string.format': 'Invalid format',
  'string.date': 'Invalid date',
  'string.time': 'Invalid time',
  'string.phone': 'Invalid phone number',
  'string.contains': 'Must contain "{substring}"',
  'string.starts_with': 'Must start with "{prefix}"',
  'string.ends_with': 'Must end with "{suffix}"',
  'string.equals': 'Must be equal to "{expected}"',
  'string.alpha': 'Must contain only letters',
  'string.alphanumeric': 'Must contain only letters and numbers',
  'string.slug': 'Must be a valid slug',
  'string.password':
      'Password must have at least 8 characters, including uppercase, lowercase, digit, and special character',
  'string.jwt': 'Invalid JWT',
  'string.card': 'Invalid credit card number',
  'string.base64': 'Invalid Base64',
  'string.hex_color': 'Invalid hex color',
  'string.mac': 'Invalid MAC address',
  'string.semver': 'Invalid Semantic Version',
  'string.mongo_id': 'Invalid MongoDB ObjectId',
  'string.ulid': 'Invalid ULID',
  'string.nano_id': 'Invalid NanoID',
  'string.iban': 'Invalid IBAN',
  'string.json': 'Invalid JSON',
  'string.cvv': 'Invalid CVV',
  'string.postal_code': 'Invalid {name}',
  'string.tax_id': 'Invalid {name}',
  'string.license_plate': 'Invalid {name}',

  'number.too_small': 'Must be at least {min}',
  'number.too_big': 'Must be at most {max}',
  'number.not_in_range': 'Must be between {min} and {max}',
  'number.positive': 'Must be positive',
  'number.negative': 'Must be negative',
  'number.multiple_of': 'Must be a multiple of {factor}',
  'number.finite': 'Must be finite',

  'int.required': 'Required',
  'int.invalid_type': 'Expected {expected}, received {received}',
  'int.even': 'Must be even',
  'int.odd': 'Must be odd',
  'int.prime': 'Must be prime',

  'double.required': 'Required',
  'double.invalid_type': 'Expected {expected}, received {received}',
  'double.decimal': 'Must be a decimal number',
  'double.integer': 'Must be an integer',

  'bool.required': 'Required',
  'bool.invalid_type': 'Expected {expected}, received {received}',
  'bool.is_true': 'Must be true',
  'bool.is_false': 'Must be false',

  'date.required': 'Required',
  'date.invalid_type': 'Expected {expected}, received {received}',
  'date.too_small': 'Must be after {date}',
  'date.too_big': 'Must be before {date}',
  'date.not_in_range': 'Must be between {min} and {max}',
  'date.weekday': 'Must be a weekday',
  'date.weekend': 'Must be a weekend',
  'date.age': 'Age is out of the allowed range',

  'array.required': 'Required',
  'array.invalid_type': 'Expected {expected}, received {received}',
  'array.too_small': 'Must have at least {min} items',
  'array.too_big': 'Must have at most {max} items',
  'array.unique': 'Must contain unique values',
  'array.contains_all': 'Must contain all required values',

  'map.required': 'Required',
  'map.invalid_type': 'Expected {expected}, received {received}',
  'map.unrecognized_key': 'Unrecognized key "{key}"',
  'map.fields_not_equal': '{field} must be equal to {other}',

  'object.required': 'Required',
  'object.invalid_type': 'Expected {expected}, received {received}',

  'enum.required': 'Required',
  'enum.invalid_type': 'Expected {expected}, received {received}',
  'enum.invalid': 'Invalid value. Expected one of: {values}',

  'literal.required': 'Required',
  'literal.invalid_type': 'Expected {expected}, received {received}',
  'literal.invalid': 'Expected "{expected}", received "{received}"',

  'union.required': 'Required',
  'union.invalid_type': 'Expected {expected}, received {received}',
  'union.invalid': 'Value does not match any of the union types',
}));

Interpolation tokens — each key can use {param} placeholders that are substituted at validation time. The most common ones: {min}, {max}, {length}, {factor}, {expected}, {received}, {substring}, {prefix}, {suffix}, {date}, {key}, {field}, {other}, {values}, {name} (for pluggable patterns like postal codes and tax IDs). Leaving a token in the translated string preserves the dynamic value in the output; omit tokens you don't want to render.

Nested format

Same content, grouped by type — easier to maintain when translating several keys of the same namespace.

V.setLocale(const VLocale({
  'required': 'Required',
  'invalid_type': 'Expected {expected}, received {received}',
  'custom': 'Invalid value',

  'string': {
    'required': 'Required',
    'invalid_type': 'Expected {expected}, received {received}',
    'not_empty': 'Must not be empty',
    'too_small': 'Must be at least {min} characters',
    'too_big': 'Must be at most {max} characters',
    'length': 'Must be exactly {length} characters',
    'integer': 'Must be a valid integer',
    'numeric': 'Must be a valid number',
    'email': 'Invalid email address',
    'url': 'Invalid URL',
    'uuid': 'Invalid UUID',
    'ip': 'Invalid IP address',
    'format': 'Invalid format',
    'date': 'Invalid date',
    'time': 'Invalid time',
    'phone': 'Invalid phone number',
    'contains': 'Must contain "{substring}"',
    'starts_with': 'Must start with "{prefix}"',
    'ends_with': 'Must end with "{suffix}"',
    'equals': 'Must be equal to "{expected}"',
    'alpha': 'Must contain only letters',
    'alphanumeric': 'Must contain only letters and numbers',
    'slug': 'Must be a valid slug',
    'password':
        'Password must have at least 8 characters, including uppercase, lowercase, digit, and special character',
    'jwt': 'Invalid JWT',
    'card': 'Invalid credit card number',
    'base64': 'Invalid Base64',
    'hex_color': 'Invalid hex color',
    'mac': 'Invalid MAC address',
    'semver': 'Invalid Semantic Version',
    'mongo_id': 'Invalid MongoDB ObjectId',
    'ulid': 'Invalid ULID',
    'nano_id': 'Invalid NanoID',
    'iban': 'Invalid IBAN',
    'json': 'Invalid JSON',
    'cvv': 'Invalid CVV',
    'postal_code': 'Invalid {name}',
    'tax_id': 'Invalid {name}',
    'license_plate': 'Invalid {name}',
  },

  'number': {
    'too_small': 'Must be at least {min}',
    'too_big': 'Must be at most {max}',
    'not_in_range': 'Must be between {min} and {max}',
    'positive': 'Must be positive',
    'negative': 'Must be negative',
    'multiple_of': 'Must be a multiple of {factor}',
    'finite': 'Must be finite',
  },

  'int': {
    'required': 'Required',
    'invalid_type': 'Expected {expected}, received {received}',
    'even': 'Must be even',
    'odd': 'Must be odd',
    'prime': 'Must be prime',
  },

  'double': {
    'required': 'Required',
    'invalid_type': 'Expected {expected}, received {received}',
    'decimal': 'Must be a decimal number',
    'integer': 'Must be an integer',
  },

  'bool': {
    'required': 'Required',
    'invalid_type': 'Expected {expected}, received {received}',
    'is_true': 'Must be true',
    'is_false': 'Must be false',
  },

  'date': {
    'required': 'Required',
    'invalid_type': 'Expected {expected}, received {received}',
    'too_small': 'Must be after {date}',
    'too_big': 'Must be before {date}',
    'not_in_range': 'Must be between {min} and {max}',
    'weekday': 'Must be a weekday',
    'weekend': 'Must be a weekend',
    'age': 'Age is out of the allowed range',
  },

  'array': {
    'required': 'Required',
    'invalid_type': 'Expected {expected}, received {received}',
    'too_small': 'Must have at least {min} items',
    'too_big': 'Must have at most {max} items',
    'unique': 'Must contain unique values',
    'contains_all': 'Must contain all required values',
  },

  'map': {
    'required': 'Required',
    'invalid_type': 'Expected {expected}, received {received}',
    'unrecognized_key': 'Unrecognized key "{key}"',
    'fields_not_equal': '{field} must be equal to {other}',
  },

  'object': {
    'required': 'Required',
    'invalid_type': 'Expected {expected}, received {received}',
  },

  'enum': {
    'required': 'Required',
    'invalid_type': 'Expected {expected}, received {received}',
    'invalid': 'Invalid value. Expected one of: {values}',
  },

  'literal': {
    'required': 'Required',
    'invalid_type': 'Expected {expected}, received {received}',
    'invalid': 'Expected "{expected}", received "{received}"',
  },

  'union': {
    'required': 'Required',
    'invalid_type': 'Expected {expected}, received {received}',
    'invalid': 'Value does not match any of the union types',
  },
}));

Mixing formats

Both forms can coexist in the same map — use flat for one-off overrides and nested for bulk translations. The resolver treats them identically.

V.setLocale(const VLocale({
  'required': 'Campo obrigatório',
  'string': {
    'email': 'E-mail inválido',
    'too_small': 'Mínimo de {min} caracteres',
  },
  'number.positive': 'Deve ser positivo',
}));

Extensibility #

The add() method is public, so any code — your own project or an external package — can plug custom validators into a schema. Write a Validator<T> once and you have two ways to use it.

class CpfValidator extends Validator<String> {
  const CpfValidator();

  @override
  String get code => 'invalid_cpf';

  @override
  Map<String, dynamic>? validate(String value) =>
      _isValid(value) ? null : {};
}

Use it directly — no extension needed:

final cpfSchema = V.string().add(const CpfValidator());

cpfSchema.validate('529.982.247-25'); // true
cpfSchema.validate('111.111.111-11'); // false

// Chains normally with other validators:
V.string().min(11).add(const CpfValidator());

Or wrap it in an extension for nicer ergonomics when you reuse the same validator across the codebase (or ship a package):

extension VStringBr on VString {
  VString cpf({String? message}) =>
      add(const CpfValidator(), message: message);
}

V.string().cpf();

Pluggable patterns #

Five country-specific validators accept pluggable pattern strategies — external packages can contribute new implementations without forking the core. Every one of them takes a list of patterns, and validation succeeds when the value matches any pattern in the list:

Validator Abstract class Built-ins shipped in core
phone(patterns:) PhonePattern E164PhonePattern (default when patterns is omitted)
card(brands:) CardBrandPattern VisaBrand, MastercardBrand, AmexBrand, DinersBrand, DiscoverBrand, JcbBrand
postalCode(patterns:) PostalCodePattern UsZipPattern, CaPostalCodePattern, UkPostcodePattern
taxId(patterns:) TaxIdPattern UsSsnPattern, UkNiNumberPattern, CaSinPattern
licensePlate(patterns:) LicensePlatePattern UkPlatePattern

Country-specific IDs (e.g. BR CPF/CNPJ, CEP, Mercosul plates) live in extension packages like validart_br. A multi-country system can list every accepted pattern in a single schema — no need to build a V.union of separate phone/postal/tax schemas.

// Custom phone pattern
class BrPhonePattern extends PhonePattern {
  const BrPhonePattern();

  @override
  String get code => 'invalid_phone_br';

  @override
  Map<String, dynamic>? validate(String value) { ... }
}

V.string().phone(patterns: [const BrPhonePattern()]);

// Multi-country in a single schema
V.string().postalCode(patterns: [
  const UsZipPattern(),
  const CaPostalCodePattern(),
  const UkPostcodePattern(),
  const BrCepPattern(), // from validart_br
]);

// Custom card brand
class EloBrand extends CardBrandPattern {
  const EloBrand();

  @override
  String get name => 'Elo';

  @override
  bool matches(String digits) { ... } // digits already stripped of spaces/dashes
}

V.string().card(brands: [const EloBrand()]);

Error-code behavior with multiple patterns

  • phone: the first pattern's code is emitted when only one pattern is configured (preserves custom codes like invalid_phone_br). With two or more, the generic VStringCode.phone ('string.phone') is emitted.
  • postalCode / taxId / licensePlate: the code is always the generic one ('string.postal_code', 'string.tax_id', 'string.license_plate'). The {name} interpolation param joins each pattern's name with / — a single template like 'Invalid {name}' renders as Invalid US ZIP / UK Postcode when multiple are configured.

License #

See LICENSE for details.

3
likes
160
points
44
downloads

Documentation

API reference

Publisher

verified publisheredunatalec.com

Weekly Downloads

A type-safe validation library for Dart, inspired by Zod. Supports parse/safeParse, transforms, coercion, schema composition, and structured errors.

Repository (GitHub)
View/report issues
Contributing

Topics

#validation #schema #parsing #form #i18n

License

MIT (license)

More

Packages that depend on validart