Skip to content

Understanding the Validasi Engine

The Validasi engine is the core component that orchestrates preprocessing, type checking, rule execution, and result creation.

Engine Architecture

ValidasiEngine<T, TInput> uses dual generics:

  • T: validated output type
  • TInput: accepted input type for validate()

By default, schema builders use TInput = T. When you call withPreprocess, the returned engine can accept a different input type.

Validation Pipeline

When you call validate(), the engine runs this sequence:

text
Input Value

Preprocess (optional)

Type Check (against T)

Rule Loop

Build Result

Return ValidasiResult<T>

Stage 1: Preprocessing

Preprocessing runs first (if configured) and is responsible for converting input into T.

dart
final schema = Validasi.number<int>([
  Rules.number.moreThan(0),
]).withPreprocess((String input) => int.parse(input));

final result = schema.validate('42');
print(result.data); // 42

If preprocessing throws or fails, validation returns a Preprocess error.

Stage 2: Type Check

After preprocessing, engine verifies the value matches T (nullable-aware check).

dart
if (processedValue is! T?) {
  return ValidasiResult<T>.error(
    ValidationError(
      rule: 'TypeCheck',
      message: 'Expected type $T, got ${processedValue.runtimeType}',
      details: {'value': processedValue},
    ),
  );
}

This keeps dynamic inputs safe at runtime.

Stage 3: Rule Loop

Rules run in declaration order. Each rule receives the current value (T?) and a mutable ValidationState.

dart
final schema = Validasi.string([
  Transform((s) => s?.trim()),
  Rules.string.minLength(3),
]);

Important behavior:

  • Rules modify value by returning a new value from apply()
  • Rules add errors via state.addError(...)
  • Rules can stop further execution with state.isStopped = true
  • Rules with runOnNull = false are skipped on null values

Stage 4: Result Build

The engine returns a ValidasiResult<T>:

dart
final result = schema.validate(input);

if (result.isValid) {
  print(result.data);
} else {
  for (final error in result.errors) {
    print('${error.rule}: ${error.message}');
  }
}

Async Validation Pipeline

When you call validateAsync(), the engine runs the same pipeline stages but uses applyRulesAsync() instead of applyRules(). Sync rules run via their inherited default applyAsync(), which delegates to apply(). Async rules (AsyncRule<T>) override applyAsync() with native async logic.

dart
final schema = Validasi.string([
  Rules.required(),
  Rules.inlineAsync((email) async {
    final taken = await repository.isTaken(email!);
    return !taken;
  }),
]);

final result = await schema.validateAsync('user@example.com');
  • If any rule is an AsyncRule, calling validate() throws StateError.
  • Container rules (HasFields, ForEach, AllValues, AnyOf) automatically support async children via their applyAsync() overrides.
  • Preprocess stays sync in both paths.

withPreprocess Type Behavior

withPreprocess changes the accepted input type at compile time.

dart
final base = Validasi.number<int>([
  Rules.number.moreThanEqual(0),
]);

final fromString = base.withPreprocess((String s) => int.parse(s));

fromString.validate('10'); // OK
// fromString.validate(10); // Compile-time error

Dynamic Input Strategy

If input is unknown at compile time:

  • Use ValidasiEngine<T, dynamic> when callers are truly dynamic
  • Prefer withPreprocess with explicit input type when possible

This gives the best balance of static safety and runtime validation.

Released under the MIT License.