axion 1.0.0
axion: ^1.0.0 copied to clipboard
A lightweight, Zod-inspired, type-safe validation library for Dart with fluent API, coercion, transformations, async refinements, and i18n support.
axion #
A lightweight, Zod-inspired, type-safe validation library for Dart.
Axion gives you a clean, fluent API for validating strings, numbers, booleans, dates, lists, objects, enums, literals, unions, and more — with built-in coercion, transformations, custom rules, async refinements, and full i18n support.
Table of Contents #
- Installation
- Quick Start
- Configuration
- Schemas
- Common Modifiers
- Coerce Namespace
- Parsing
- Object Utilities
- Error Handling
- API Reference
Installation #
Add axion to your pubspec.yaml:
dependencies:
axion: latest
Then run:
dart pub get
Quick Start #
import 'package:axion/axion.dart';
void main() {
configureAxion((config) {
config.translations = axionTranslationsEn;
});
final schema = object({
'name': string().min(2).max(50),
'email': string().email(),
'age': integer().min(0).max(120),
'phone': string().phone(),
});
final result = schema.parseSafe({
'name': 'Alice',
'email': 'alice@example.com',
'age': 30,
'phone': '+5511999999999',
});
if (result.success) {
print(result.data); // → {name: Alice, email: alice@example.com, ...}
} else {
print(result.error?.issues); // → list of AxionIssue
}
}
Configuration #
Translations #
Axion ships with English (axionTranslationsEn) and Portuguese (axionTranslationsPt) translation maps. Apply one globally before using any schema.
Switching language
// English (default)
configureAxion((config) => config.translations = axionTranslationsEn);
// Portuguese
configureAxion((config) => config.translations = axionTranslationsPt);
Pluralization with transChoice
Several built-in messages (e.g. string.min, array.min) contain pipe-separated segments that are selected at runtime based on a count value. This uses the same Laravel-style transChoice format:
| Syntax | Meaning |
|---|---|
{0} … |
Exact match when count == 0 |
{1} … |
Exact match when count == 1 |
[2,5] … |
Range match: 2 ≤ count ≤ 5 |
[2,*] … |
Range match: count ≥ 2 |
singular|plural |
Implicit two-part: first for count == 1, second otherwise |
The special placeholder :count is always replaced with the count value.
// Built-in string.min message:
// '{1} Must be at least :count character|[2,*] Must be at least :count characters'
//
// count = 1 → 'Must be at least 1 character'
// count = 5 → 'Must be at least 5 characters'
string().min(5).parseSafe('hi'); // error message uses count = 5
You can call transChoice directly for your own use:
import 'package:axion/axion.dart';
transChoice('{0} No items|{1} One item|[2,*] :count items', 0); // → 'No items'
transChoice('{0} No items|{1} One item|[2,*] :count items', 1); // → 'One item'
transChoice('{0} No items|{1} One item|[2,*] :count items', 7); // → '7 items'
Placeholder interpolation
Plain string messages support {placeholder} tokens that are replaced from the args map passed to translate():
// Built-in: 'string.startsWith': 'This value should start with "{prefix}"'
// When called with args = {'prefix': 'https://'} → 'This value should start with "https://"'
Custom messages in refine() and superRefine() are plain strings and do not go through interpolation — they are used as-is.
MessageBuilder callbacks
For fully dynamic messages you can register a MessageBuilder — a String Function(Map<String, dynamic> args) — as the value for any key:
configureAxion((config) {
config.translations = {
...axionTranslationsEn,
'string.min': (args) {
final n = args['count'] as int;
return n == 1
? 'Must be at least 1 character'
: 'Must be at least $n characters';
},
};
});
Overriding individual keys
You don't have to replace the whole map. Spread the base map and override only the keys you need:
configureAxion((config) {
config.translations = {
...axionTranslationsEn,
'required': 'Field is required',
'string.email': 'Please enter a valid e-mail',
};
});
You can also call configureAxion multiple times — each call merges into the global config singleton.
Custom Global Rules #
Register named synchronous or asynchronous rules once and reuse them across schemas:
configureAxion((config) {
config.translations = axionTranslationsEn;
// Sync rule
config.rule('no-spaces', (val) => !val.toString().contains(' '));
// Async rule (e.g. DB lookup)
config.ruleAsync('unique-email', (val) async {
return await checkEmailIsUnique(val as String);
});
});
// Apply in a schema
final schema = string().email().rule('no-spaces');
final asyncSchema = string().email().ruleAsync('unique-email');
Schemas #
string() #
Validates String values. All validators return the same StringSchema for chaining.
string() // must be a String
.min(3) // minimum length
.max(100) // maximum length
.length(8) // exact length
.email() // valid e-mail
.url() // valid http/https URL
.uuid() // valid RFC 4122 UUID
.datetime() // parseable ISO 8601 datetime
.phone() // valid international phone number
.phone({'BR'}) // restrict to a country
.iban() // valid IBAN (mod-97 checksum)
.creditCard() // valid credit card (Luhn)
.ip() // valid IPv4
.ipv6() // valid IPv6
.alphanumeric() // only [A-Za-z0-9]
.ascii() // only printable ASCII
.hexColor() // #fff or #1a2b3c
.slug() // lowercase-letters-and-hyphens
.startsWith('https://')
.endsWith('.dart')
.contains('@')
.regex(RegExp(r'^\d{4}$'))
.trim() // transform: strip whitespace
.toLowerCase() // transform: to lower-case
.toUpperCase(); // transform: to upper-case
number() / integer() / double_() #
number() // accepts any num
integer() // accepts only int
double_() // accepts num, promotes to double
// Shared validators
.min(0)
.max(100)
.positive()
.negative()
.nonNegative()
.multipleOf(5)
.between(1, 10)
boolean() #
boolean() // accepts true or false
.defaultValue(false) // returns false when input is null
date() #
Validates DateTime values. Use coerce.date() to automatically parse ISO 8601 strings.
date()
.after(DateTime(2000, 1, 1))
.before(DateTime.now())
.minDate(DateTime(2020))
.maxDate(DateTime(2030))
array() #
array() // any list
.of(string().email()) // item-level validation
.min(1) // minimum items
.max(10) // maximum items
.length(3) // exact item count
.unique() // no duplicates
.nonempty() // at least one item (shorthand for .min(1))
object() #
Validates Map<String, dynamic> values against a shape definition.
final addressSchema = object({
'street': string().min(3),
'city': string(),
'zip': string().regex(RegExp(r'^\d{5}$')),
});
By default, keys not declared in the shape are silently stripped from the output. You can change this behaviour with two modifiers:
strict()
Rejects the input if it contains any keys not declared in the shape:
final schema = object({'name': string()}).strict();
schema.parse({'name': 'Alice', 'extra': 'x'}); // throws AxionException (unrecognized_keys)
schema.parse({'name': 'Alice'}); // → {name: Alice}
passthrough()
Allows unknown keys to pass through into the parsed output:
final schema = object({'name': string()}).passthrough();
schema.parse({'name': 'Alice', 'extra': 'x'}); // → {name: Alice, extra: x}
Utility methods
final base = object({
'name': string(),
'email': string().email(),
'age': integer(),
});
// Add new fields
final extended = base.extend({'role': string()});
// Merge two schemas
final merged = base.merge(extended);
// Keep only specific fields
final slim = base.pick(['name', 'email']);
// Remove specific fields
final withoutAge = base.omit(['age']);
// Make all fields optional (useful for PATCH-style payloads)
final patchSchema = base.partial();
axionEnum() #
final statusSchema = axionEnum(['active', 'inactive', 'pending']);
statusSchema.parse('active'); // → 'active'
statusSchema.parse('unknown'); // throws AxionException
literal() #
final schema = literal('admin');
schema.parse('admin'); // → 'admin'
schema.parse('user'); // throws AxionException
union() #
Tries each schema in order and returns the first match:
final idOrName = union([integer(), string()]);
idOrName.parse(1); // → 1
idOrName.parse('alice'); // → 'alice'
dUnion() #
Discriminated union — selects the correct ObjectSchema based on a literal key:
final petSchema = dUnion('type', [
object({'type': literal('cat'), 'lives': integer()}),
object({'type': literal('dog'), 'breed': string()}),
]);
petSchema.parse({'type': 'cat', 'lives': 9});
petSchema.parse({'type': 'dog', 'breed': 'Labrador'});
any() #
Accepts any non-null value without type checks:
final schema = any().optional();
schema.parse('hello'); // → 'hello'
schema.parse(42); // → 42
schema.parse(null); // → null
Common Modifiers #
All modifiers are available on every schema type.
optional() #
Returns null when the input is null instead of throwing:
string().optional().parse(null); // → null
nullable() #
Accepts null as a valid, explicitly-typed value:
string().nullable().parse(null); // → null
defaultValue() #
Returns a fallback when the input is null:
integer().defaultValue(0).parse(null); // → 0
coerce() #
Attaches a custom coercer that runs before type validation:
integer().coerce((v) => int.tryParse(v.toString()) ?? v).parse('42'); // → 42
transform() #
Maps the validated value to a new value (after all refinements pass):
string()
.transform((val) => val.trim().toLowerCase())
.parse(' Hello '); // → 'hello'
refine() #
Appends a synchronous predicate with a custom message:
string().refine(
(val) => val != 'admin',
message: 'Username "admin" is reserved',
);
superRefine() #
Gives full control over issue creation:
string().superRefine((val, ctx) {
if (val.contains(' ')) {
ctx.addIssue(code: 'no_spaces', message: 'No spaces allowed');
}
if (val.length < 3) {
ctx.addIssue(code: 'too_short', message: 'Too short');
}
});
Async Refinements #
Use refineAsync() or superRefineAsync() for async checks (e.g., database lookups). Always call parseAsync() when using these:
final schema = string().email().refineAsync((val, ctx) async {
final exists = await checkEmailExists(val);
if (exists) ctx.addIssue(message: 'Email already taken');
});
final result = await schema.parseSafeAsync('alice@example.com');
Coerce Namespace #
The global coerce instance provides schemas with sensible built-in coercers:
| Factory | Coercion |
|---|---|
coerce.string() |
value.toString() |
coerce.integer() |
int.tryParse(value.toString()) |
coerce.doubleType() |
double.tryParse(value.toString()) |
coerce.boolean() |
'true'/'1' → true, 'false'/'0' → false |
coerce.date() |
DateTime.tryParse(value) from ISO 8601 string |
coerce.integer().parse('42'); // → 42
coerce.doubleType().parse('3.14'); // → 3.14
coerce.boolean().parse('true'); // → true
coerce.boolean().parse('0'); // → false
coerce.date().parse('2024-01-15'); // → DateTime(2024, 1, 15)
Parsing #
parse() #
Parses synchronously. Throws AxionException on failure:
final name = string().min(2).parse('Alice'); // → 'Alice'
string().min(10).parse('hi'); // throws AxionException
parseSafe() #
Returns a SafeParseResult instead of throwing:
final result = string().email().parseSafe('not-an-email');
if (result.success) {
print(result.data);
} else {
for (final issue in result.error!.issues) {
print('${issue.path.join('.')}: ${issue.message}');
}
}
parseAsync() / parseSafeAsync() #
Required when the schema contains refineAsync or superRefineAsync:
final value = await schema.parseAsync(input);
final result = await schema.parseSafeAsync(input);
if (result.success) { ... }
parseAs() / parseSafeAs() #
Useful when a transform() changes the output type:
final schema = integer().transform((n) => n.toString());
final str = schema.parseAs<String>(42); // → '42'
Object Utilities #
ObjectSchema provides several utility methods to compose and slice schemas, as well as modifiers to control how unknown keys are handled.
By default, unknown keys are stripped from the parsed output. Use strict() to reject them or passthrough() to keep them.
final base = object({
'name': string(),
'email': string().email(),
'age': integer(),
});
// Reject inputs with keys not in the shape
final strictSchema = base.strict();
// Allow unknown keys to pass through into the output
final looseSchema = base.passthrough();
// Add new fields
final extended = base.extend({'role': string()});
// Merge two schemas
final merged = base.merge(extended);
// Keep only specific fields
final slim = base.pick(['name', 'email']);
// Remove specific fields
final withoutAge = base.omit(['age']);
// Make all fields optional (for PATCH-style payloads)
final patchSchema = base.partial();
Error Handling #
AxionException carries a list of AxionIssue objects. Each issue has:
| Property | Type | Description |
|---|---|---|
code |
String |
Machine-readable code (e.g. 'string.email', 'required') |
path |
List<String> |
Dot-path to the offending field (empty for top-level) |
message |
String |
Human-readable error message (translated) |
try {
object({
'email': string().email(),
'age': integer().positive(),
}).parse({'email': 'bad', 'age': -1});
} on AxionException catch (e) {
for (final issue in e.issues) {
print('[${issue.path.join('.')}] ${issue.message}');
// [email] Invalid email address
// [age] Must be a positive number
}
}
API Reference #
Schema factories #
| Factory | Type | Description |
|---|---|---|
string() |
StringSchema |
String validation |
number() |
NumberSchema |
num validation |
integer() |
IntegerSchema |
int validation |
double_() |
DoubleSchema |
double validation |
boolean() |
BooleanSchema |
bool validation |
date() |
DateSchema |
DateTime validation |
array() |
ArraySchema |
List validation |
object(shape) |
ObjectSchema |
Map<String,dynamic> validation |
axionEnum(values) |
EnumSchema<T> |
Fixed set of allowed values |
literal(value) |
LiteralSchema<T> |
Exact value match |
union(schemas) |
UnionSchema |
First-match union |
dUnion(key, schemas) |
DiscriminatedUnionSchema |
Discriminated union |
any() |
AnySchema |
Accept any value |
Coerce namespace #
| Factory | Description |
|---|---|
coerce.string() |
Coerces to String |
coerce.integer() |
Coerces to int |
coerce.doubleType() |
Coerces to double |
coerce.boolean() |
Coerces to bool |
coerce.date() |
Coerces ISO 8601 string to DateTime |
Common modifiers #
| Method | Description |
|---|---|
.optional() |
Allows null, returns null |
.nullable() |
Accepts null as a valid value |
.defaultValue(v) |
Returns v when input is null |
.coerce(fn) |
Runs fn before type validation |
.transform(fn) |
Maps the validated value |
.refine(fn, {message}) |
Synchronous predicate |
.superRefine(fn) |
Synchronous multi-issue refinement |
.refineAsync(fn) |
Async predicate |
.superRefineAsync(fn) |
Async multi-issue refinement |
.rule(name) |
Apply a registered global sync rule |
.ruleAsync(name) |
Apply a registered global async rule |
Parse methods #
| Method | Returns | Throws? |
|---|---|---|
.parse(input) |
T |
✅ AxionException |
.parseSafe(input) |
SafeParseResult<T> |
❌ |
.parseAs<R>(input) |
R |
✅ |
.parseSafeAs<R>(input) |
SafeParseResult<R> |
❌ |
.parseAsync(input) |
Future<T> |
✅ |
.parseSafeAsync(input) |
Future<SafeParseResult<T>> |
❌ |
.parseAsyncAs<R>(input) |
Future<R> |
✅ |
.parseSafeAsyncAs<R>(input) |
Future<SafeParseResult<R>> |
❌ |
ObjectSchema utilities #
| Method | Description |
|---|---|
.strict() |
Reject inputs with keys not declared in the shape |
.passthrough() |
Allow unknown keys to pass through into the output |
.extend(shape) |
New schema with extra fields |
.merge(other) |
New schema merging other's shape |
.pick(keys) |
New schema with only keys |
.omit(keys) |
New schema without keys |
.partial() |
New schema with all fields optional |
Global functions #
| Function / Type | Description |
|---|---|
configureAxion(fn) |
Set translations and custom rules |
transChoice(line, count, [args]) |
Select the correct plural segment and interpolate :count / args |
axionTranslationsEn |
English translation map |
axionTranslationsPt |
Portuguese translation map |
MessageBuilder |
typedef String Function(Map<String, dynamic> args) — for dynamic messages |
Translation keys #
Every built-in error message is stored under a dot-namespaced key in the translation map. You can override any of them individually.
| Key | Placeholders | Notes |
|---|---|---|
required |
— | Value is missing / null |
custom |
— | Generic custom-refinement failure |
async_required |
— | Async schema called synchronously |
string.type |
— | |
string.min |
:count |
Plural-aware |
string.max |
:count |
Plural-aware |
string.length |
:count |
Plural-aware |
string.email |
— | |
string.url |
— | |
string.uuid |
— | |
string.regex |
— | |
string.datetime |
— | |
string.startsWith |
{prefix} |
|
string.endsWith |
{suffix} |
|
string.contains |
{substring} |
|
string.phone |
— | |
string.iban |
— | |
string.creditCard |
— | |
string.ip |
— | |
string.ipv6 |
— | |
string.alphanumeric |
— | |
string.ascii |
— | |
string.hexColor |
— | |
string.slug |
— | |
number.type |
— | |
number.min |
{min} |
|
number.max |
{max} |
|
number.positive |
— | |
number.negative |
— | |
number.nonNegative |
— | |
number.multipleOf |
{value} |
|
number.between |
{min}, {max} |
|
integer.type |
— | |
double.type |
— | |
boolean.type |
— | |
date.type |
— | |
date.after |
{date} |
|
date.before |
{date} |
|
date.minDate |
{date} |
|
date.maxDate |
{date} |
|
array.type |
— | |
array.min |
:count |
Plural-aware |
array.max |
:count |
Plural-aware |
array.length |
:count |
Plural-aware |
array.unique |
— | |
object.type |
— | |
object.strict |
{key} |
Unrecognized key in strict mode |
enum.type |
— | |
literal.type |
{value} |
|
union.type |
— | |
dunion_missing_discriminator |
{key} |
|
dunion_invalid_discriminator |
{value} |
License #
MIT