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

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

Libraries

axion
Axion — a Zod-inspired, type-safe validation library for Dart.
translations/en
English translations for Axion validation messages.
translations/pt
Traduções em Português para as mensagens de validação do Axion.