Zema Logo
Getting Started

Quick Start

Define a schema, validate data, and handle the result. This guide covers the three foundational operations.

Step 1: Define a Schema

A schema describes the expected structure and constraints of a value.

import 'package:zema/zema.dart';

final userSchema = z.object({
  'id': z.integer(),
  'name': z.string().min(2),
  'email': z.string().email(),
  'age': z.integer().gte(18).optional(),
  'createdAt': z.dateTime(),
});

Each entry in the shape maps a field name to the schema that validates its value. Missing optional fields arrive as null and pass through. Missing required fields produce an invalid_type issue.


Step 2: Validate Data

safeParse validates the input and returns a ZemaResult. It never throws.

final data = {
  'id': 1,
  'name': 'Alice',
  'email': 'alice@example.com',
  'createdAt': '2024-01-15T10:30:00Z',
};

final ZemaResult<Map<String, dynamic>> result = userSchema.safeParse(data);

parse is the throwing variant. Use it when a validation failure is a programming error and a hard crash is the correct response.

// Throws ZemaException if validation fails
final Map<String, dynamic> value = userSchema.parse(data);

Step 3: Handle the Result

ZemaResult is a sealed class with two variants: ZemaSuccess and ZemaFailure. Use pattern matching or the when method to handle both cases exhaustively.

Pattern matching

switch (result) {
  case ZemaSuccess(:final value):
    print('ID: ${value['id']}');
    print('Email: ${value['email']}');
  case ZemaFailure(:final errors):
    for (final issue in errors) {
      print('${issue.pathString}: ${issue.message}');
    }
}

when method

result.when(
  success: (value) => print('User: ${value['name']}'),
  failure: (errors) => print('${errors.length} issue(s) found'),
);

Imperative checks

if (result.isSuccess) {
  final value = result.value; // Map<String, dynamic>
}

if (result.isFailure) {
  final errors = result.errors; // List<ZemaIssue>
}

Step 4: Access Error Details

Each ZemaIssue carries a stable code, a human-readable message, and the path to the failing field.

// Invalid input
final badData = {
  'id': 1,
  'name': 'A',           // too short (min 2)
  'email': 'not-valid',  // invalid email
  'createdAt': '2024-01-15T10:30:00Z',
};

final result = userSchema.safeParse(badData);

if (result.isFailure) {
  for (final issue in result.errors) {
    print('[${issue.code}] ${issue.pathString}: ${issue.message}');
  }
}
// [too_short] name: String is too short (min: 2, actual: 1)
// [invalid_email] email: Invalid email address

Step 5: Map to a Typed Model

When the output is a Map<String, dynamic>, use mapTo to apply a constructor without unwrapping the result manually.

// Freezed or hand-written model
class User {
  final int id;
  final String name;
  final String email;

  const User({required this.id, required this.name, required this.email});

  factory User.fromJson(Map<String, dynamic> json) => User(
    id: json['id'] as int,
    name: json['name'] as String,
    email: json['email'] as String,
  );
}

final ZemaResult<User> userResult = userSchema.safeParse(data).mapTo(User.fromJson);

mapTo forwards the failure unchanged if validation failed. User.fromJson is called only on success.


Step 6: Zero-Cost Typed Access with Extension Types

Extension Types wrap the validated map at compile time with zero runtime allocation.

extension type User(Map<String, dynamic> _) {
  int get id => _['id'] as int;
  String get name => _['name'] as String;
  String get email => _['email'] as String;
  int? get age => _['age'] as int?;
}

At runtime, User is the Map<String, dynamic> itself. No constructor call. No heap allocation. Type-safe field access is enforced at compile time.

if (result.isSuccess) {
  final user = result.value;
  print(user.email); // compile-time checked, zero-cost at runtime
}

Next Steps

Copyright © 2026