Zema Logo
Getting Started

Core Concepts

The foundational abstractions of Zema: schemas, ZemaResult, the immutability invariant, and the validation pipeline.

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 by safeParse. Commonly dynamic for schemas that validate untrusted external data.
  • Output: the validated, possibly-transformed type produced on success. Input and Output differ 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:

  1. Type check: input must be a String. Fails fast with invalid_type.
  2. Trim: if .trim() was called, whitespace is stripped before subsequent checks.
  3. Length: .min() and .max() checked against the (possibly trimmed) length.
  4. Pattern: .regex() match tested.
  5. Format: .email(), .url(), .uuid() tested in that order.
  6. 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.

MethodSignatureBehaviour
.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():

Inputz.integer()z.coerce().integer()
42validvalid
42.0invalid_typevalid (whole number only)
'42'invalid_typevalid
'42.5'invalid_typeinvalid_coercion

Next Steps

Copyright © 2026