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 typeTInput: accepted input type forvalidate()
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:
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.
final schema = Validasi.number<int>([
Rules.number.moreThan(0),
]).withPreprocess((String input) => int.parse(input));
final result = schema.validate('42');
print(result.data); // 42If preprocessing throws or fails, validation returns a Preprocess error.
Stage 2: Type Check
After preprocessing, engine verifies the value matches T (nullable-aware check).
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.
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 = falseare skipped on null values
Stage 4: Result Build
The engine returns a ValidasiResult<T>:
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.
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, callingvalidate()throwsStateError. - Container rules (
HasFields,ForEach,AllValues,AnyOf) automatically support async children via theirapplyAsync()overrides. - Preprocess stays sync in both paths.
withPreprocess Type Behavior
withPreprocess changes the accepted input type at compile time.
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 errorDynamic Input Strategy
If input is unknown at compile time:
- Use
ValidasiEngine<T, dynamic>when callers are truly dynamic - Prefer
withPreprocesswith explicit input type when possible
This gives the best balance of static safety and runtime validation.
