The simplest way to validate Flutter form fields β a small, composable set of rules
that plug straight into any TextFormField.validator.
- β Zero dependencies β pure Dart, nothing to ship but your code
- π§© Composable β stack rules; the first failure wins
- π Extensible β write your own rule by implementing one method
- π Localizable β translate every error message per rule type
- π§ͺ Well tested β every rule is covered by unit tests
- Installation
- Quick start
- How it works
- API reference
- Examples for every rule
- Usage guide
- Good to know
- Migrating to 2.0.0
- License
Add the dependency to your pubspec.yaml:
dependencies:
x_validators: ^2.0.0Then run flutter pub get (or dart pub get).
import 'package:x_validators/x_validators.dart';xValidator takes a list of rules and returns a String? Function(String?) β
exactly the signature Flutter's form fields expect:
TextFormField(
decoration: const InputDecoration(labelText: 'Email'),
validator: xValidator([
const IsRequired(),
const IsEmail(),
]),
);The returned function yields null when the value is valid, or the error message
of the first failing rule otherwise.
Every rule is a small class that extends TextXValidationRule and answers one
question β bool isValid(String input). xValidator runs the rules in order
and stops at the first one that fails:
xValidator([ ruleβ, ruleβ, ruleβ ]) βββΆ String? Function(String? value)
β
value ββΆ ruleβ.isValid? β yes ββΆ ruleβ.isValid? β yes ββΆ ruleβ ... ββΆ null (valid)
β no β no
βΌ βΌ
error message error message (first failure wins)
Because order matters, put broad rules first (e.g. IsRequired) and specific
rules after (e.g. IsEmail).
Most rules accept an optional positional error message as their last
argument (e.g. IsRequired('This field is required')); a few (Match,
ContainsAny) take it as a named error parameter alongside their other named
options. When omitted, the rule's default message is used (see
Localizing error messages).
| Rule | Description |
|---|---|
IsRequired([error]) |
Fails when the trimmed input is empty. |
IsEmpty([error]) |
Passes only when the trimmed input is empty. |
Contains(value, [error]) |
Input contains value (compared after trimming). |
NotContains(value, [error]) |
Input does not contain value. |
StartsWith(prefix, [error]) |
Input starts with prefix. |
EndsWith(suffix, [error]) |
Input ends with suffix. |
Match(value, {caseSensitive = true}, [error]) |
Input equals value; case-insensitive when caseSensitive is false. |
MinLength(min, [error]) |
Trimmed length is >= min. |
MaxLength(max, [error]) |
Trimmed length is <= max. |
| Rule | Description |
|---|---|
IsNumber([error]) |
Input is an integer (use IsDecimal for fractional values). |
IsDecimal([error]) |
Input is a decimal number (integers are accepted too). |
IsArabicNum([error]) |
Positive integer written in Latin digits 0-9 (no leading zero). |
IsHindiNum([error]) |
Number written in Arabic-Indic digits Ω -Ω©. |
MinValue(min, [error]) |
Parsed numeric value is >= min. |
MaxValue(max, [error]) |
Parsed numeric value is <= max. |
| Rule | Description |
|---|---|
IsUrl([error]) |
Input is a valid http/https URL. |
IsSecureUrl([error]) |
Input is a URL using the https:// scheme. |
IsFacebookUrl([error]) |
Input is a valid Facebook URL. |
IsInstagramUrl([error]) |
Input is a valid Instagram URL. |
IsYoutubeUrl([error]) |
Input is a valid YouTube URL. |
| Rule | Description |
|---|---|
IsEgyptianPhone([error]) |
Input is a valid Egyptian phone number. |
ISKsaPhone([error]) |
Input is a valid Saudi Arabian phone number. |
| Rule | Description |
|---|---|
IsEmail([error]) |
Input is a valid email address. |
IsBool([error]) |
Input is a valid boolean value. |
IsIpAddress([error]) |
Input is a valid IP address. |
IsPort([error]) |
Input is a valid port number. |
RegExpRule(regExp, [error]) |
Input matches the provided RegExp. |
| Rule | Description |
|---|---|
IsIn(values, [error]) |
Input is one of the values in the provided list. |
IsNotIn(values, [error]) |
Input is not in the provided list. |
ContainsAny(values, {caseSensitive = false, error}) |
Input contains at least one item from the list (case-insensitive by default). |
NotContainsAny(values, [error]) |
Input contains none of the items in the list. |
| Rule | Description |
|---|---|
IsDate([error]) |
Input is a parseable date string. |
IsDateMillis([error]) |
Input is an integer of milliseconds since epoch. |
IsDateAfter(date, [error]) |
Input is a date later than date. |
| Rule | Description |
|---|---|
IsArabicChars([error]) |
Input consists of Arabic letters, whitespace and Arabic-Indic digits Ω -Ω©. |
IsEnglishChars([error]) |
Only English letters AβZ (no spaces or digits). |
IsNumbersOnly([error]) |
Input is all digits (one or more). |
IsLtrLanguage([error]) |
Input is a left-to-right language code. |
IsRTLLanguage([error]) |
Input is a right-to-left language code. |
| Rule | Description |
|---|---|
IsHexColor([error]) |
Input is a valid 3/6/8-digit hex color. |
| Rule | Description |
|---|---|
IsOptional() |
When present, an empty field skips all other rules and passes. |
Every rule is shown below with a passing and a failing input. The examples use
rule.isValid(value) so you can see the boolean directly; inside a form you'd
normally pass the rule to xValidator([...]) and get the error message instead
(see Composing rules). Each rule also has a matching
top-level function (isEmail, isHexColor, β¦) for one-off checks β see
Standalone helper functions.
const IsRequired().isValid('Jane'); // β true
const IsRequired().isValid(' '); // β false (trimmed empty)
const IsEmpty().isValid(' '); // β true
const IsEmpty().isValid('x'); // β false
const Contains('@').isValid('a@b.com'); // β true
const Contains('@').isValid('abc'); // β false
const NotContains(' ').isValid('no_spaces'); // β true
const NotContains(' ').isValid('has space'); // β false
const StartsWith('+20').isValid('+20100'); // β true
const StartsWith('+20').isValid('0100'); // β false
const EndsWith('.com').isValid('site.com'); // β true
const EndsWith('.com').isValid('site.org'); // β false
const Match('Yes').isValid('Yes'); // β true (case-sensitive by default)
const Match('Yes').isValid('yes'); // β false
const Match('Yes', caseSensitive: false).isValid('yes'); // β true
const MinLength(3).isValid('abc'); // β true
const MinLength(3).isValid('ab'); // β false
const MaxLength(5).isValid('abcde'); // β true
const MaxLength(5).isValid('abcdef'); // β falseconst IsNumber().isValid('42'); // β true
const IsNumber().isValid('-7'); // β true
const IsNumber().isValid('3.14'); // β false (use IsDecimal)
const IsNumber().isValid('0x1A'); // β false (no hex)
const IsDecimal().isValid('3.14'); // β true
const IsDecimal().isValid('42'); // β true (integers too)
const IsDecimal().isValid('abc'); // β false
const IsArabicNum().isValid('123'); // β true (Latin digits, positive)
const IsArabicNum().isValid('012'); // β false (leading zero)
const IsArabicNum().isValid('0'); // β false
const IsHindiNum().isValid('Ω‘Ω’Ω£'); // β true (Arabic-Indic digits)
const IsHindiNum().isValid('Ω Ω‘Ω’'); // β false (leading Ω )
const IsHindiNum().isValid('123'); // β false (Latin digits)
const MinValue(18).isValid('18'); // β true
const MinValue(18).isValid('17'); // β false
const MaxValue(100).isValid('100'); // β true
const MaxValue(100).isValid('101'); // β falseconst IsUrl().isValid('https://my-site.co.uk'); // β true
const IsUrl().isValid('https://a.b.example.com/p?x=1'); // β true
const IsUrl().isValid('example.com'); // β false (no scheme)
const IsSecureUrl().isValid('https://x.com'); // β true
const IsSecureUrl().isValid('http://x.com'); // β false
const IsFacebookUrl().isValid('https://facebook.com/me'); // β true
const IsFacebookUrl().isValid('https://facebook.com.evil.com'); // β false
const IsInstagramUrl().isValid('https://instagram.com/me'); // β true
const IsInstagramUrl().isValid('https://evil.com'); // β false
const IsYoutubeUrl().isValid('https://youtube.com/watch?v=x'); // β true
const IsYoutubeUrl().isValid('https://vimeo.com/1'); // β falseconst IsEgyptianPhone().isValid('01012345678'); // β true (010/011/012/015 + 8 digits)
const IsEgyptianPhone().isValid('01312345678'); // β false (013 is not a valid prefix)
const ISKsaPhone().isValid('0512345678'); // β true
const ISKsaPhone().isValid('+966512345678'); // β true
const ISKsaPhone().isValid('0612345678'); // β falseconst IsEmail().isValid('jane.doe@example.com'); // β true
const IsEmail().isValid('plainaddress'); // β false
const IsBool().isValid('true'); // β true
const IsBool().isValid(' FALSE '); // β true (trimmed, case-insensitive)
const IsBool().isValid('yes'); // β false
const IsIpAddress().isValid('192.168.1.1'); // β true
const IsIpAddress().isValid('192.168.001.001'); // β false (leading zeros)
const IsPort().isValid('8080'); // β true (0β65535)
const IsPort().isValid('65536'); // β false
RegExpRule(RegExp(r'^[A-Z]{3}$')).isValid('EGP'); // β true
RegExpRule(RegExp(r'^[A-Z]{3}$')).isValid('usd'); // β falseconst IsIn(['red', 'green', 'blue']).isValid('green'); // β true
const IsIn(['red', 'green', 'blue']).isValid('yellow'); // β false
const IsNotIn(['admin', 'root']).isValid('guest'); // β true
const IsNotIn(['admin', 'root']).isValid('admin'); // β false
const ContainsAny(['http', 'https']).isValid('https://x'); // β true
const ContainsAny(['http', 'https']).isValid('ftp://x'); // β false
const ContainsAny(['USD']).isValid('usd ok'); // β true (case-insensitive)
const ContainsAny(['USD'], caseSensitive: true).isValid('usd ok'); // β false
const NotContainsAny(['<', '>']).isValid('safe'); // β true
const NotContainsAny(['<', '>']).isValid('a<b'); // β falseconst IsDate().isValid('2026-06-01'); // β true
const IsDate().isValid('not-a-date'); // β false
const IsDateMillis().isValid('1700000000000'); // β true
const IsDateMillis().isValid('3.14'); // β false
IsDateAfter(DateTime(2020)).isValid('2026-01-01'); // β true
IsDateAfter(DateTime(2020)).isValid('2019-01-01'); // β falseconst IsArabicChars().isValid('Ω
Ψ±ΨΨ¨Ψ§ Ψ¨Ω'); // β true
const IsArabicChars().isValid('Ω
Ψ±ΨΨ¨Ψ§ Ω‘Ω’Ω£'); // β true (Arabic-Indic digits allowed)
const IsArabicChars().isValid('hello'); // β false
const IsEnglishChars().isValid('Hello'); // β true
const IsEnglishChars().isValid('Hello World'); // β false (no spaces)
const IsEnglishChars().isValid('abc123'); // β false (letters only)
const IsNumbersOnly().isValid('12345'); // β true
const IsNumbersOnly().isValid('12a'); // β false
const IsLtrLanguage().isValid('en'); // β true
const IsLtrLanguage().isValid('ar'); // β false
const IsRTLLanguage().isValid('ar'); // β true
const IsRTLLanguage().isValid('en'); // β falseconst IsHexColor().isValid('#FFF'); // β true (3 digits)
const IsHexColor().isValid('A1B2C3'); // β true (6 digits, '#' optional)
const IsHexColor().isValid('#FF0000FF'); // β true (8 digits, with alpha)
const IsHexColor().isValid('red'); // β falseIsOptional only makes sense inside a validator: an empty value skips the rest
and passes, but a non-empty value still has to satisfy them.
final validate = xValidator([
const IsOptional(),
const IsEmail(),
]);
validate(''); // β null (empty is allowed)
validate('a@b.co'); // β null (valid email)
validate('x'); // β IsEmail's message (non-empty must be valid)Rules run top-to-bottom and the first failure is returned, so order them from general to specific:
final validate = xValidator([
const IsRequired(), // checked first
const MinLength(8),
const MaxLength(32),
]);
validate(''); // β IsRequired's message
validate('abc'); // β MinLength's message
validate('hunter2!'); // β null (valid)Ready-to-paste validators for the fields you actually build. Each returns the
first failing rule's message, or null when the value passes β exactly what
TextFormField.validator expects.
Password β required, length-bounded, must contain a special character:
final password = xValidator([
const IsRequired('Password is required'),
const MinLength(8, 'At least 8 characters'),
const MaxLength(64, 'At most 64 characters'),
const ContainsAny(['!', '@', '#', '%'], error: 'Add a special character'),
]);
password(''); // β 'Password is required'
password('abc'); // β 'At least 8 characters'
password('abcdefgh'); // β 'Add a special character'
password('abcdefg!'); // β null (valid)Email β required and well-formed:
final email = xValidator([
const IsRequired('Email is required'),
const IsEmail('Enter a valid email'),
]);
email(''); // β 'Email is required'
email('not-an-email'); // β 'Enter a valid email'
email('jane@acme.io'); // β null (valid)Phone β required Egyptian mobile number:
final phone = xValidator([
const IsRequired('Phone is required'),
const IsEgyptianPhone('Enter a valid Egyptian number'),
]);
phone('0100'); // β 'Enter a valid Egyptian number'
phone('01012345678'); // β null (valid)Optional age β blank is allowed, but if filled it must be a whole number in
range. Put IsOptional first so an empty value skips the rest and passes:
final age = xValidator([
const IsOptional(),
const IsNumber('Digits only'),
const MinValue(18, 'Must be 18 or older'),
const MaxValue(120, 'Enter a real age'),
]);
age(''); // β null (optional β blank is fine)
age('1x'); // β 'Digits only'
age('16'); // β 'Must be 18 or older'
age('30'); // β null (valid)Optional website β blank allowed, otherwise a valid secure URL:
final website = xValidator([
const IsOptional(),
const IsUrl('Enter a valid URL'),
const IsSecureUrl('Use https://'),
]);
website(''); // β null (optional β blank is fine)
website('not a url'); // β 'Enter a valid URL'
website('http://insecure.io'); // β 'Use https://'
website('https://acme.io'); // β null (valid)Pass a message as the rule's last argument to override its default:
xValidator([
const IsRequired('Please enter a value'),
const MinLength(8, 'Use at least 8 characters'),
]);Useful for analytics or logging. The callback receives the input, the full rule list, and the rule that failed:
xValidator(
[
const IsRequired('Field cannot be empty'),
const MinLength(3, 'At least 3 characters'),
const MaxLength(20, 'At most 20 characters'),
],
onFailureCallBack: (input, rules, failedRule) {
debugPrint('Validation failed for "$input" on ${failedRule.runtimeType}');
},
);Add IsOptional to let an empty field pass while still validating non-empty
input. Here an empty value is accepted, but anything typed must be a valid email:
xValidator([
const IsOptional(),
const IsEmail(),
]);Extend TextXValidationRule, implement isValid, and (optionally) override
defaultMessage to provide a default message:
class StartsWithCapital extends TextXValidationRule {
const StartsWithCapital([super.error]);
@override
bool isValid(String input) =>
input.isNotEmpty && input[0] == input[0].toUpperCase();
@override
String get defaultMessage => 'Must start with a capital letter';
}
// Use it like any built-in rule:
xValidator([const StartsWithCapital()]);Overriding
toString()still works β the basedefaultMessagedelegates to it β butdefaultMessageis the preferred hook for a rule's default message.
Register a translator per rule type with XValidatorsLocalization.on<T>().
When that rule fails (and no inline error was given), your function supplies
the message:
XValidatorsLocalization.on<IsRequired>((rule) => 'ΩΨ°Ψ§ Ψ§ΩΨΩΩ Ω
Ψ·ΩΩΨ¨');
XValidatorsLocalization.on<IsEmail>((rule) => 'Ψ§ΩΨ¨Ψ±ΩΨ― Ψ§ΩΨ₯ΩΩΨͺΨ±ΩΩΩ ΨΊΩΨ± Ψ΅Ψ§ΩΨ');
final validate = xValidator([const IsRequired()]);
validate(''); // β 'ΩΨ°Ψ§ Ψ§ΩΨΩΩ Ω
Ψ·ΩΩΨ¨'Resolution order for a failing rule is: inline error β registered
translator β rule.defaultMessage.
Each rule also ships a top-level function for one-off checks outside of a form, mirroring the rule's logic:
isNotEmpty('hello'); // true
isEmpty(' '); // true
minLength('abc', 3); // true
EmailXValidator.validate('test@example.com'); // true- A
nullvalue passed to the validator is treated as an empty string'', soIsRequired(and every other rule) sees''and a required field correctly fails onnull. Add anIsOptionalrule if you want an empty/nullvalue to skip the remaining rules and pass. (Before 2.0.0,nullshort-circuited the whole validator to "valid" β see Migrating to 2.0.0.)
2.0.0 is a behavior-only major release: the public API shape is unchanged (same
rules, same xValidator signature), but several rules were tightened to do what
their names promise. Most apps need no code changes β the table below lists every
behavior change and how to restore the old behavior where it's recoverable.
| Area | Before (1.x) | After (2.0.0) | How to adapt |
|---|---|---|---|
null input |
Short-circuited the whole validator to valid | Treated as '', so IsRequired rejects it |
Add IsOptional to let empty/null pass |
IsNumber |
Accepted decimals, hex, scientific (3.14, 1e3, 0x1A) |
Integers only | Use the new IsDecimal for fractional values |
IsArabicChars |
\p{N} token matched the literal chars p{N} and no digits |
Accepts Arabic-Indic digits Ω -Ω© only |
β (this was a bug; Latin 123 was never really allowed) |
IsNumbersOnly |
Passed if the input contained a digit ('abc1', '12 34') |
Passes only when the input is all digits | Use Contains/RegExpRule for "contains a digit" |
IsFacebookUrl / IsInstagramUrl / IsYoutubeUrl |
Unanchored β facebook.com.evil.com passed |
Fully anchored β domain-suffix spoofing is rejected | β (intended hardening) |
IsUrl |
Rejected hyphens, deep subdomains, long TLDs | Accepts my-site.co.uk, a.b.example.com, example.museum |
β (relaxation only) |
IsIpAddress |
Tolerated leading zeros and whitespace (192.168.001.001, ' 1.2.3.4') |
Strict dotted-quad | Trim/normalize before validating if needed |
ContainsAny |
error was positional; caseSensitive was a dead no-op |
error is named; caseSensitive works |
See snippet below |
IsArabicNum / IsHindiNum |
Both used key validation.must_be_num |
validation.must_be_arabic_num / validation.must_be_hindi_num |
Re-key your translators (inline error: is unaffected) |
isInstgramUrlValid |
Typo'd free function | Renamed to isInstagramUrlValid |
Old name still works as a @Deprecated alias |
// 1.x β accepted "3.14"
xValidator([const IsNumber()]);
// 2.0.0 β integers only; use IsDecimal for fractional input
xValidator([const IsDecimal()]);// 1.x
const ContainsAny(['a', 'b'], 'Pick one'); // positional error
final r = ContainsAny(['a'])..caseSensitive = true; // dead no-op field
// 2.0.0
const ContainsAny(['a', 'b'], error: 'Pick one'); // named error
const ContainsAny(['a'], caseSensitive: true); // actually case-sensitiveIf you imported individual rule files directly, note two internal renames:
text/is_not_empty.dartβtext/is_required.dartandurls/is_instgram_url.dartβurls/is_instagram_url.dart. Importing the package barrel (package:x_validators/x_validators.dart) needs no change.
See LICENSE.
|
Mahmoud Basuony Software Engineer If x_validators saved you some boilerplate, a β on the repo is appreciated. |
