LucidValidation is a pure Dart package for building strongly-typed validation rules, inspired by FluentValidation. Developed by the Flutterando community, this package offers a fluent, expressive, and highly extensible API for both frontend (Flutter) and backend applications.
- Strongly Typed: Errors are caught at compile-time, with full autocomplete support.
- Fluent API: Expressive chainable validators that read like natural language.
- Highly Extensible: Easily add custom validation rules via Dart extensions.
- Polymorphic Validations: Handle complex conditional flows out of the box using
switchOnandwhen/unlessbranches. - Unified Validation System: Write validation rules once and reuse them across frontend (Flutter) and backend applications.
- Deep Nesting Support: Effortlessly validate complex object graphs, lists of items, and simple collections with index tracking.
- Built-in Localization: Localized messages out of the box with custom translation capabilities.
Add lucid_validation to your project using the Dart CLI:
dart pub add lucid_validationOr for Flutter projects:
flutter pub add lucid_validationCreate your domain or data model class:
class UserModel {
final String email;
final String password;
final int age;
final DateTime dateOfBirth;
UserModel({
required this.email,
required this.password,
required this.age,
required this.dateOfBirth,
});
}Extend LucidValidator and declare your validation rules in the constructor:
import 'package:lucid_validation/lucid_validation.dart';
class UserValidator extends LucidValidator<UserModel> {
UserValidator() {
final now = DateTime.now();
ruleFor((user) => user.email, key: 'email')
.notEmpty()
.validEmail();
ruleFor((user) => user.password, key: 'password')
.notEmpty()
.minLength(8, message: 'Must be at least 8 characters long')
.mustHaveLowercase()
.mustHaveUppercase()
.mustHaveNumber()
.mustHaveSpecialCharacter();
ruleFor((user) => user.age, key: 'age')
.min(18, message: 'Minimum age is 18 years');
ruleFor((user) => user.dateOfBirth, key: 'dateOfBirth')
.lessThan(DateTime(now.year - 18, now.month, now.day));
}
}Instantiate your validator and validate your model instances:
void main() {
final user = UserModel(
email: 'test@example.com',
password: 'Passw0rd!',
age: 25,
dateOfBirth: DateTime(2001, 1, 1),
);
final validator = UserValidator();
final result = validator.validate(user);
if (result.isValid) {
print('User is valid!');
} else {
print('Validation errors: ${result.exceptions.map((e) => e.message).join(', ')}');
}
}For simple, lightweight, or dynamically configured validation needs, you do not need to declare a dedicated class extending LucidValidator. You can construct a ready-to-use validator inline using the LucidValidator.inline factory constructor.
This is extremely useful for:
- Quick validation logic within a controller, API handler, controller action, or leaving your validations alongside your form (Flutter).
- Dynamic validation rules created on the fly.
- Nested or sub-property validations passed to
.setValidator()or.setEach().
Tip
Keep your codebase lean by using inline validators for one-off tasks, and reserve class-based validators (e.g. UserValidator extends LucidValidator<User>) for reusable domain model validations.
final validator = LucidValidator<UserModel>.inline((v) {
v.ruleFor((user) => user.email, key: 'email')
.notEmpty()
.validEmail();
v.ruleFor((user) => user.password, key: 'password')
.notEmpty()
.minLength(8);
});
// Run validation normally
final result = validator.validate(user);The returned ValidationResult provides helpers for extracting error details, which is especially useful for rendering forms or building API responses:
final result = validator.validate(user);
// Check overall status
bool isValid = result.isValid;
// Map containing errors grouped by field key — perfect for JSON API payloads
Map<String, List<String>> errors = result.errorsByKey;
// Output: { "email": ["'email' is not a valid email address."], "password": [...] }
// Retrieve only the first error message for a specific key
String? emailError = result.firstErrorFor('email');If your application architecture prefers exceptions over manual boolean checks, use validateAndThrow (or its asynchronous equivalent validateAndThrowAsync). This will raise a LucidValidationException if validation fails:
try {
validator.validateAndThrow(user);
} on LucidValidationException catch (e) {
print(e.exceptions); // Access the list of ValidationExceptions
}If you want to retrieve raw validation exceptions directly without checking ValidationResult.exceptions, you can use the following methods:
getExceptions(entity): Returns a flatList<ValidationException>of all failures encountered.getExceptionsByKey(entity, key): Returns only the validation exceptions related to a specific property key.
// Retrieve all validation exceptions
final allExceptions = validator.getExceptions(user);
// Retrieve exceptions only for the email field
final emailExceptions = validator.getExceptionsByKey(user, 'email');For interactive user interfaces, you often want to show a real-time checklist (for example, password strength requirements) that updates as the user types.
Instead of writing duplicate logic, you can use rulesForField (or rulesForFieldAsync if any rules are asynchronous) to evaluate and retrieve the status of all rules registered for a specific field, regardless of whether they currently pass or fail.
Each returned ExposedRuleResult contains:
code: The unique rule code (e.g.notEmpty,minLength).message: The formatted error message for that rule.isValid: A boolean indicating if the entity currently satisfies that specific rule.
final user = UserModel(
email: 'test@example.com',
password: 'short', // Fails length and character requirements
age: 25,
dateOfBirth: DateTime(2001, 1, 1),
);
final validator = UserValidator();
final rules = validator.rulesForField(user, 'password');
for (final rule in rules) {
print('${rule.isValid ? '✓' : '✗'} [${rule.code}]: ${rule.message}');
}
// Output:
// ✓ [notEmpty]: 'password' must not be empty.
// ✗ [minLength]: Must be at least 8 characters long
// ✗ [mustHaveLowercase]: 'password' must have at least one lowercase letter.
// ...LucidValidation includes a rich set of built-in validation rules.
Tip
Almost all built-in rules have a nullable counterpart ending with the OrNull suffix (e.g., validEmailOrNull()), which permits null values but validates them if they are present.
| Validator | Target Type | Description |
|---|---|---|
must(predicate) |
T |
Custom validation using a synchronous predicate. |
mustWith(predicate) |
T |
Custom validation with access to the parent entity. |
mustAsync(predicate) |
T |
Custom asynchronous validation. |
mustWithAsync(predicate) |
T |
Custom asynchronous validation with access to the parent entity. |
isNull() |
T? |
Checks if the value is null. |
isNotNull() |
T? |
Checks if the value is not null. |
equalTo(otherField) |
T |
Checks if the value is equal to another field's value. |
notEqualTo(otherField) |
T |
Checks if the value is not equal to another field's value. |
isInEnum(values) |
T |
Checks if the value is contained within a list of allowed values (e.g. MyEnum.values). |
| Validator | Description |
|---|---|
isEmpty() / notEmpty() |
Checks if a string is empty or contains only whitespace / not empty. |
minLength(min) / maxLength(max) |
Asserts string length boundaries. |
length(min, max) |
Asserts string length is within a specific range. |
isAlphanumeric() |
Checks if the string contains only letters and digits. |
isNumeric() |
Checks if the string contains only numeric characters. |
isUppercase() / isLowercase() |
Checks if the string is entirely uppercase / lowercase. |
contains(substring) |
Checks if the string contains the specified substring. |
startsWith(prefix) / endsWith(suffix) |
Checks if the string starts/ends with a substring. |
matchesPattern(regex) |
Checks if the string matches a regular expression pattern. |
mustHaveLowercase() / mustHaveUppercase() |
Requires at least one lowercase / uppercase letter. |
mustHaveNumber() / mustHaveSpecialCharacter() |
Requires at least one numeric character / special character. |
hasNoSequentialRepeatedCharacters(length) |
Ensures there are no sequences of repeated characters (e.g. "aaa"). |
hasNoSequentialCharacters(length) |
Ensures there are no sequences of sequential characters (e.g. "123", "abc"). |
| Validator | Description |
|---|---|
min(val) / max(val)
|
Asserts a number is greater than/equal to val or less than/equal to val. |
range(min, max) |
Asserts a number is between min and max (inclusive). |
inclusiveBetween(min, max) |
Asserts value is within a range, including the bounds. |
exclusiveBetween(min, max) |
Asserts value is within a range, excluding the bounds. |
isPositive() / isNegative()
|
Checks if a number is positive ( |
isNonNegative() / isNonZero()
|
Checks if a number is non-negative ( |
isEven() / isOdd()
|
Checks if a number is even / odd. |
multipleOf(factor) |
Checks if a number is a multiple of a given factor. |
precisionScale(precision, scale) |
Asserts maximum total digits (precision) and decimal places (scale). |
| Validator | Description |
|---|---|
isTrue() / isFalse() |
Checks if the boolean value is strictly true / false. |
| Validator | Description |
|---|---|
greaterThan(dateTime) / lessThan(dateTime) |
Checks if value is strictly after / before a specified DateTime. |
greaterThanOrEqualTo(dateTime) / lessThanOrEqualTo(dateTime) |
Checks if value is after / before or equal to a specified DateTime. |
inclusiveBetween(min, max) / exclusiveBetween(min, max) |
Asserts date is within a range, including / excluding bounds. |
inPast() / inFuture() |
Checks if the DateTime is in the past / future relative to evaluation time. |
afterField(otherField) |
Checks if the date is after another DateTime property of the same entity. |
beforeField(otherField) |
Checks if the date is before another DateTime property of the same entity. |
| Validator | Target Type | Description |
|---|---|---|
minItems(min) / maxItems(max) |
List<T> |
Asserts list item counts. |
listContains(item) |
List<T> |
Checks if the list contains a specific item. |
| Validator | Target Type | Description |
|---|---|---|
validEmail() |
String |
Asserts a valid email format. |
validUrl() / httpUrl() |
String |
Asserts a valid URL / valid HTTP or HTTPS URL. |
validUuid() |
String |
Asserts a valid UUID (v4, v5, etc.). |
validIpv4() / validIpv6() |
String |
Asserts a valid IPv4 / IPv6 address format. |
validCreditCard() |
String |
Asserts a valid credit card format using Luhn algorithm. |
| Validator | Target Type | Description |
|---|---|---|
validCPF() / validCNPJ() |
String |
Asserts valid Brazilian CPF / CNPJ digits. |
validCPFOrCNPJ() / validCEP() |
String |
Asserts valid Brazilian CPF/CNPJ (combined check) / CEP format. |
validPhoneBR() |
String |
Asserts a valid Brazilian phone format (e.g., (xx) 9xxxx-xxxx or xx9xxxxxxxx). |
validPhoneWithCountryCodeBR() |
String |
Asserts a valid Brazilian phone format with country code (e.g., +55xx9xxxxxxxx). |
Using lucid_validation with Flutter TextFormField validation is clean and straightforward. Leverage the byField method to bind validation directly to your form fields.
You can do this by extending LucidValidator or by declaring the validator inline directly alongside your widget using LucidValidator.inline.
import 'package:flutter/material.dart';
import 'package:lucid_validation/lucid_validation.dart';
class LoginForm extends StatelessWidget {
final validator = CredentialsValidation();
final credentials = CredentialsModel();
LoginForm({super.key});
@override
Widget build(BuildContext context) {
return Form(
child: Column(
children: [
TextFormField(
decoration: const InputDecoration(hintText: 'Email'),
validator: validator.byField(credentials, 'email'),
),
TextFormField(
decoration: const InputDecoration(hintText: 'Password'),
validator: validator.byField(credentials, 'password'),
obscureText: true,
),
],
),
);
}
}If you want to keep your validation rules right beside your form widget without creating a separate validator class:
import 'package:flutter/material.dart';
import 'package:lucid_validation/lucid_validation.dart';
class LoginForm extends StatelessWidget {
final credentials = CredentialsModel();
// Define the validator inline within your widget
final validator = LucidValidator<CredentialsModel>.inline((v) {
v.ruleFor((c) => c.email, key: 'email')
.notEmpty()
.validEmail();
v.ruleFor((c) => c.password, key: 'password')
.notEmpty()
.minLength(8);
});
LoginForm({super.key});
@override
Widget build(BuildContext context) {
return Form(
child: Column(
children: [
TextFormField(
decoration: const InputDecoration(hintText: 'Email'),
validator: validator.byField(credentials, 'email'),
),
TextFormField(
decoration: const InputDecoration(hintText: 'Password'),
validator: validator.byField(credentials, 'password'),
obscureText: true,
),
],
),
);
}
}Note
Architectural Recommendation: Although inline validation is convenient for rapid UI prototyping or local forms, LucidValidation strongly recommends adopting Domain-Driven Design (DDD) practices. Ideally, your validation rules should reside in your project's Domain Layer as part of your core business logic. This ensures that your entities are kept valid across all application layers (controllers, APIs, and UI views).
Cascade Mode controls validation rule execution once a failure is detected on a property.
CascadeMode.continueExecution(Default): Executes all rules in the validation chain for a property, even if preceding rules failed. Use this to collect all errors at once.CascadeMode.stopOnFirstFailure: Aborts further checks on that specific property as soon as a failure is encountered. Excellent for stopping heavy or redundant checks.
Apply cascade settings using the .cascade() method at the end of the rule chain:
ruleFor((user) => user.password, key: 'password')
.notEmpty()
.minLength(8)
.mustHaveLowercase()
.mustHaveUppercase()
.mustHaveNumber()
.mustHaveSpecialCharacter()
.cascade(CascadeMode.stopOnFirstFailure); // Halts validation on the first error found in this chainApplies validation rules in a builder only if the provided condition evaluates to true:
ruleFor((user) => user.phoneNumber, key: 'phoneNumber')
.when((user) => user.requiresPhoneNumber)
.notEmpty()
.must((value) => value.length == 10, message: 'Phone number must be 10 digits', code: 'phone_length');The inverse of when: rules run only when the condition evaluates to false:
ruleFor((user) => user.companyName, key: 'companyName')
.unless((user) => user.isIndividual)
.notEmpty();switchOn routes an entire block of rules based on a discriminator value (such as an enum, String, or int) derived from the entity. Only the rules for the matching case are executed; all others are ignored.
This is ideal for polymorphic entities where required fields change depending on the type selected:
enum PaymentType { creditCard, pix, bankTransfer }
class PaymentValidator extends LucidValidator<Payment> {
PaymentValidator() {
// Always-on rule: runs regardless of payment type
ruleFor((p) => p.type, key: 'type').isNotNull();
switchOn((p) => p.type, cases: {
PaymentType.creditCard: (v) {
v.ruleFor((p) => p.cardNumber, key: 'cardNumber').notEmpty().minLength(16);
v.ruleFor((p) => p.cvv, key: 'cvv').isNumeric().length(3, 4);
},
PaymentType.pix: (v) {
v.ruleFor((p) => p.pixKey, key: 'pixKey').notEmpty();
},
}, orElse: (v) {
// Runs when no registered cases match
v.ruleFor((p) => p.type, key: 'type')
.must((_) => false, message: 'Unsupported payment type.', code: 'unsupported_type');
});
}
}Note
Unlike when, which applies to a single property chain, switchOn validates or skips a grouped block of different fields at once.
Instead of inline parameters, you can customize the preceding rule in your chain using fluent extensions mirroring FluentValidation:
ruleFor((user) => user.email, key: 'email')
.notEmpty().withMessage('Please inform your email.')
.validEmail().withErrorCode('EMAIL_INVALID');withMessage(msg): Overrides the error message of the preceding rule.withErrorCode(code): Overrides the error code of the preceding rule.withName(displayName): Sets a user-friendly display name (e.g.'Date of birth'instead of'dateOfBirth') for error messages, while preserving the key for field mapping:
ruleFor((user) => user.dateOfBirth, key: 'dateOfBirth')
.withName('Date of birth')
.notEmpty(); // Output message: "'Date of birth' must not be empty."Transform and sanitize input values (e.g., trim whitespace, lower-case, or remove masks) before validations execute. This affects only the validation engine and does not mutate the original model:
ruleFor((user) => user.email, key: 'email')
.normalize((email) => email.trim().toLowerCase())
.notEmpty()
.validEmail();For validation rules needing network calls, database lookups, or file system access, use mustAsync / mustWithAsync and execute your checks with validateAsync or validateAndThrowAsync:
class UserValidator extends LucidValidator<UserModel> {
UserValidator(UserRepository repository) {
ruleFor((user) => user.email, key: 'email')
.notEmpty()
.validEmail()
.mustAsync(
(email) async => !await repository.emailExists(email),
message: 'Email already registered',
code: 'email_taken',
);
}
}
// Validation run:
final result = await validator.validateAsync(user);Important
If any validation chain contains asynchronous rules, calling the synchronous validate() or byField() will throw an AsyncValidationException. Always use validateAsync for asynchronous rules.
Rule Sets allow you to partition rules for different workflows (such as creating vs updating entities). Rules defined outside any explicitly declared ruleSet are placed in the default set.
class UserValidator extends LucidValidator<UserModel> {
UserValidator() {
// Default rule set
ruleFor((u) => u.email, key: 'email').notEmpty().validEmail();
// Custom 'create' rule set
ruleSet('create', () {
ruleFor((u) => u.password, key: 'password').notEmpty().minLength(8);
});
}
}
final validator = UserValidator();
validator.validate(user); // Runs only default rules (email)
validator.validate(user, ruleSets: ['create']); // Runs only 'create' rules (password)
validator.validate(user, ruleSets: ['*']); // Runs all rules (default + create)You can merge all rules from another validator targeting the same entity type. This prevents code repetition and keeps validators modular:
class FullUserValidator extends LucidValidator<UserModel> {
FullUserValidator() {
include(ContactInfoValidator());
include(SecurityValidator());
}
}To validate nested objects, assign a dedicated validator to the child property using the setValidator method:
class Customer {
final String name;
final Address address;
Customer({required this.name, required this.address});
}
class Address {
final String country;
final String postcode;
Address({required this.country, required this.postcode});
}
// Validators
class AddressValidator extends LucidValidator<Address> {
AddressValidator() {
ruleFor((address) => address.country, key: 'country').notEmpty();
ruleFor((address) => address.postcode, key: 'postcode').notEmpty();
}
}
class CustomerValidator extends LucidValidator<Customer> {
CustomerValidator() {
ruleFor((customer) => customer.name, key: 'name').notEmpty();
ruleFor((customer) => customer.address, key: 'address').setValidator(AddressValidator());
}
}To fetch error validation status on a nested field using byField, use dot-notation:
final validator = CustomerValidator();
final postcodeValidationFunc = validator.byField(customer, 'address.postcode');
final errorMsg = postcodeValidationFunc(); // Returns the validation error for postcode, if any.To validate a list of child objects, use setEach to apply a validator to each element individually. This tracks item index numbers in case of failure:
class Classroom {
final String className;
final TeacherModel teacher;
final List<StudentModel> students;
Classroom({required this.className, required this.teacher, required this.students});
}
class ClassroomValidator extends LucidValidator<Classroom> {
ClassroomValidator() {
ruleFor((c) => c.className, key: 'className').notEmpty();
ruleFor((c) => c.teacher, key: 'teacher').setValidator(TeacherValidator());
ruleFor((c) => c.students, key: 'students').setEach(StudentValidator());
}
}In the validation exceptions, index tracking is handled automatically:
final result = validator.validate(classroom);
final exceptions = result.exceptions;
// If the first student in the list failed validation:
print(exceptions[2].key); // "name"
print(exceptions[2].index); // 0 (index of the failing item)If you are validating collections containing simple primitive types (e.g. List<String>), use ruleForEach:
ruleForEach((order) => order.tags, key: 'tags')
.notEmpty()
.maxLength(20);By default, error messages are generated in English. You can switch culture globally:
LucidValidation.global.culture = Culture('pt', 'BR');To define custom translations or override existing messages, extend the LanguageManager:
class CustomLanguageManager extends LanguageManager {
CustomLanguageManager() {
addTranslation(Culture('pt', 'BR'), Language.code.equalTo, 'Mensagem customizada aqui');
}
}
// Register custom language manager
LucidValidation.global.languageManager = CustomLanguageManager();Integrate localization directly with Flutter's standard localization workflow using a custom LocalizationsDelegate:
class LucidLocalizationDelegate extends LocalizationsDelegate<Culture> {
const LucidLocalizationDelegate();
static const delegate = LucidLocalizationDelegate();
@override
bool isSupported(Locale locale) {
return LucidValidation.global.languageManager.isSupported(
locale.languageCode,
locale.countryCode,
);
}
@override
Future<Culture> load(Locale locale) async {
final culture = Culture(locale.languageCode, locale.countryCode ?? '');
LucidValidation.global.culture = culture;
return culture;
}
@override
bool shouldReload(LocalizationsDelegate<Culture> old) => true;
}Register the delegate in your MaterialApp or CupertinoApp:
@override
Widget build(BuildContext context) {
return MaterialApp(
supportedLocales: const [
Locale('en', 'US'),
Locale('pt', 'BR'),
],
localizationsDelegates: const [
LucidLocalizationDelegate.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
// ...
);
}Extend SimpleValidationBuilder<T> to build custom, reusable validation rules.
Combine existing rules into a single helper method:
extension StrongPasswordValidation on SimpleValidationBuilder<String> {
SimpleValidationBuilder<String> strongPassword() {
return notEmpty()
.minLength(8)
.mustHaveLowercase()
.mustHaveUppercase()
.mustHaveNumber()
.mustHaveSpecialCharacter();
}
}For custom logic, use useValidation. Provide a predicate (value, entity) => bool. You can optionally supply custom parameter tokens for message replacement:
extension MinWordsValidation on SimpleValidationBuilder<String> {
SimpleValidationBuilder<String> minWords(int min, {String? message, String? code}) {
return useValidation(
(value, entity) => value.trim().split(RegExp(r'\s+')).length >= min,
code: code ?? 'minWords',
message: message ?? "'{PropertyName}' must have at least {Min} words.",
parameters: (value, entity) => {'Min': '$min'},
);
}
}For rules requiring external API calls or database checks, use useValidationAsync. The predicate must return a Future<bool>:
extension UniqueUsernameValidation on SimpleValidationBuilder<String> {
SimpleValidationBuilder<String> uniqueUsername(
Future<bool> Function(String username) checkAvailable, {
String? message,
String? code,
}) {
return useValidationAsync(
(value, entity) => checkAvailable(value),
code: code ?? 'usernameTaken',
message: message ?? "'{PropertyName}' is already taken.",
);
}
}If you need absolute control over the created ValidationException on failure (for example, to dynamically format the entity type name, modify properties, or construct a highly customized payload), you can use use (sync) or useAsync (async) directly:
extension CustomRule on SimpleValidationBuilder<String> {
SimpleValidationBuilder<String> complexCustomCheck() {
return use((value, entity) {
if (value.startsWith('admin') && !entity.isAdminUser) {
return ValidationException(
key: key,
message: 'Only admin users can define admin prefixes.',
code: 'unauthorized_prefix',
entity: 'UserModel',
);
}
return null; // Return null if validation passes
});
}
}If you are using AI-powered coding assistants (such as Cursor, GitHub Copilot, Gemini, or Claude) to write or maintain validators in your codebase, we have prepared a specialized context instruction file.
Instruct your AI assistant to read the SKILL.md file, available in the root of this repository, before starting. It provides a fairly detailed guide covering built-in rules, complex nesting, custom validation extension patterns, and common architectural pitfalls.
Contributions are welcome! If you encounter issues, have feature requests, or want to contribute translations, please open an issue or submit a pull request on the GitHub Repository.
This package is licensed under the MIT License. See the LICENSE file for more information.