Core Concepts
ZemaSchema
ZemaSchema<Input, Output> is the root abstract class. Every schema is a specialisation of it.
Signature:
abstract class ZemaSchema<Input, Output> {
ZemaResult<Output> safeParse(Input value);
Output parse(Input value); // throws ZemaException on failure
}
Type parameters:
Input: the type accepted bysafeParse. Commonlydynamicfor schemas that validate untrusted external data.Output: the validated, possibly-transformed type produced on success.InputandOutputdiffer when transformations are applied.
Mechanism: parse delegates to safeParse and throws a ZemaException wrapping all collected ZemaIssues if the result is a failure. safeParse is the primary override point for new schema implementations.
The Factory: z
z is a const singleton of Zema. All schema construction goes through it.
import 'package:zema/zema.dart';
z.string() // ZemaString
z.integer() // ZemaInt
z.double() // ZemaDouble
z.boolean() // ZemaBool
z.dateTime() // ZemaDateTime
z.object({}) // ZemaObject<Map<String, dynamic>>
z.array(schema) // ZemaArray<T>
z.union([...]) // ZemaUnion<T>
z.literal(value) // ZemaLiteral<T>
z.map(k, v) // ZemaMap<K, V>
z.lazy(() => s) // ZemaSchema<I, O> — for recursive schemas
z.custom(fn) // ZemaSchema<T, T> — arbitrary predicate
z.coerce() // ZemaCoerce — coercion sub-namespace
zema is an alias for z. Both are identical.
ZemaResult
ZemaResult<T> is a sealed class. Every call to safeParse returns one of its two subtypes.
sealed class ZemaResult<T> {
bool get isSuccess;
bool get isFailure;
T get value; // throws StateError if called on ZemaFailure
List<ZemaIssue> get errors; // empty list if ZemaSuccess
}
final class ZemaSuccess<T> extends ZemaResult<T> {
final T value;
}
final class ZemaFailure<T> extends ZemaResult<T> {
final List<ZemaIssue> errors;
}
Exhaustive handling
final result = schema.safeParse(input);
// Pattern matching
switch (result) {
case ZemaSuccess(:final value):
use(value);
case ZemaFailure(:final errors):
report(errors);
}
// Functional
result.when(
success: (value) => use(value),
failure: (errors) => report(errors),
);
// Side effects only
result.onSuccess((v) => cache.put(v));
result.onError((e) => logger.warn(e));
Mapping to a typed model
// mapTo applies the constructor only on success, forwards failure unchanged.
final ZemaResult<User> userResult = schema.safeParse(json).mapTo(User.fromJson);
// mapToOrNull returns null on failure.
final User? user = schema.safeParse(json).mapToOrNull(User.fromJson);
// mapToOrElse folds both branches into a single value.
final User user = schema.safeParse(json).mapToOrElse(
User.fromJson,
onError: (_) => User.empty(),
);
ZemaIssue
ZemaIssue is an immutable value object describing a single validation failure.
final class ZemaIssue {
final String code; // stable identifier: 'invalid_type', 'too_short', …
final String message; // human-readable, localised via ZemaI18n
final List<Object> path; // field path: ['user', 'address', 'zip']
final Object? receivedValue; // the failing value, for debugging
final Map<String, dynamic>? meta; // additional context
}
pathString formats the path for display:
[] → 'root'
['email'] → 'email'
['items', 2, 'name'] → 'items.[2].name'
Immutability
Every schema instance is immutable. Fluent API methods return new instances — they do not mutate state.
final base = z.string();
final trimmed = base.trim(); // new ZemaString, base unchanged
final bounded = base.min(2); // new ZemaString, base and trimmed unchanged
This invariant makes schemas safe to define at module scope and reuse across isolates.
Composability
Schemas are values. They compose freely.
// Reuse primitive schemas
final emailSchema = z.string().email();
final ageSchema = z.integer().gte(18);
// Compose into object schemas
final userSchema = z.object({
'email': emailSchema,
'age': ageSchema,
'name': z.string().min(2),
});
// Extend an existing schema without modifying it
final adminSchema = userSchema.extend({
'role': z.string().oneOf(['admin', 'superadmin']),
'permissions': z.array(z.string()),
});
Validation Pipeline
The validation of a single field follows this sequence for ZemaString:
- Type check: input must be a
String. Fails fast withinvalid_type. - Trim: if
.trim()was called, whitespace is stripped before subsequent checks. - Length:
.min()and.max()checked against the (possibly trimmed) length. - Pattern:
.regex()match tested. - Format:
.email(),.url(),.uuid()tested in that order. - Enum:
.oneOf()checked last.
All active constraints are evaluated. All failures are collected and returned together.
Modifiers
Modifiers wrap an existing schema and alter its nullability or failure behaviour. Each returns a new schema type.
| Method | Signature | Behaviour |
|---|---|---|
.optional() | ZemaSchema<Input?, Output?> | null input passes through as null output. |
.nullable() | ZemaSchema<Input?, Output?> | Explicit null is a valid non-absent value. |
.withDefault(v) | ZemaSchema<Input?, Output> | null input substitutes v; output is never null. |
.catchError(fn) | ZemaSchema<Input, Output> | Failures are caught; fn receives the issues and returns a fallback. |
z.string().optional() // null → null
z.string().nullable() // null → null (explicit nullability)
z.string().withDefault('anon') // null → 'anon'
z.integer().gte(0).catchError((_) => 0) // invalid → 0
Transformers
Transformers alter the Output type of a schema.
.transform(fn)
Runs fn on the validated output. The schema's Output type changes to the return type of fn.
// ZemaSchema<dynamic, String> → ZemaSchema<dynamic, String>
final upper = z.string().transform((s) => s.toUpperCase());
upper.parse('hello'); // 'HELLO'
.pipe(next)
Connects two schemas. The output of this becomes the input of next.
// Parse a string, convert to int, then validate the range.
final portSchema = z.string()
.transform(int.parse)
.pipe(z.integer().gte(1).lte(65535));
portSchema.parse('8080'); // 8080
portSchema.parse('70000'); // ZemaException: too_big
.preprocess(fn)
Transforms the raw input before the schema validates it. Useful for normalising messy data.
final trimmedEmail = z.string().email().preprocess<dynamic>(
(v) => v?.toString().trim() ?? '',
);
trimmedEmail.parse(' alice@example.com '); // 'alice@example.com'
Exhaustive Validation
ZemaObject and ZemaArray validate all fields and all elements before returning. A single ZemaFailure contains every issue.
final result = z.object({
'email': z.string().email(),
'age': z.integer().gte(18),
'name': z.string().min(2),
}).safeParse({
'email': 'invalid',
'age': 15,
'name': 'A',
});
// result.errors contains three ZemaIssues — one for each failing field
This differs from fail-fast validation: the caller receives a complete error list in one pass, which is required for form validation UX.
Coercion
Coercion schemas convert compatible inputs before validating. Access via z.coerce().
z.coerce().integer() // '42' → 42, 42.0 → 42
z.coerce().float() // '3.14' → 3.14, 3 → 3.0
z.coerce().boolean() // 'true' | 1 | 'yes' → true
z.coerce().string() // 42 → '42'
z.coerce().integer() differs from z.integer():
| Input | z.integer() | z.coerce().integer() |
|---|---|---|
42 | valid | valid |
42.0 | invalid_type | valid (whole number only) |
'42' | invalid_type | valid |
'42.5' | invalid_type | invalid_coercion |