Zema Logo
Advanced

Lazy Schema

Defer schema construction with z.lazy() to define self-referential and mutually recursive schemas without circular initialization errors.

z.lazy() defers schema construction to the first parse call. Use it when a schema needs to reference itself, directly or through another schema, which would otherwise cause a circular dependency at construction time.

Signature:

ZemaSchema<I, O> lazy<I, O>(ZemaSchema<I, O> Function() fn)

The factory function fn is called once, on the first safeParse call. The result is cached, subsequent parses reuse the same schema instance.


Why lazy is needed

Schemas are built eagerly. A self-referential schema like a tree node would require the schema to already exist while constructing it, a circular dependency that causes a runtime error:

// ✗ Stack overflow — nodeSchema references itself at construction time
final nodeSchema = z.object({
  'value':    z.integer(),
  'children': z.array(nodeSchema),   // nodeSchema not yet assigned
});

z.lazy() breaks the cycle by deferring the reference lookup to parse time:

// ✓ Correct — the lambda captures nodeSchema by reference, resolved later
late final ZemaSchema<dynamic, dynamic> nodeSchema;

nodeSchema = z.object({
  'value':    z.integer(),
  'children': z.array(z.lazy(() => nodeSchema)).optional(),
});

Basic usage: tree node

late final ZemaSchema<dynamic, dynamic> nodeSchema;

nodeSchema = z.object({
  'value':    z.integer(),
  'children': z.array(z.lazy(() => nodeSchema)).optional(),
});

nodeSchema.parse({
  'value': 1,
  'children': [
    {'value': 2},
    {'value': 3, 'children': [
      {'value': 4},
    ]},
  ],
});
// valid

nodeSchema.parse({'value': 'oops'});
// throws ZemaException: invalid_type (on 'value')

Mutual recursion

Two schemas that reference each other:

late final ZemaSchema<dynamic, dynamic> expressionSchema;
late final ZemaSchema<dynamic, dynamic> statementSchema;

expressionSchema = z.union([
  z.object({'type': z.literal('number'), 'value': z.double()}),
  z.object({
    'type': z.literal('call'),
    'body': z.lazy(() => statementSchema),
  }),
]);

statementSchema = z.union([
  z.object({'type': z.literal('return'), 'expr': z.lazy(() => expressionSchema)}),
  z.object({'type': z.literal('block'), 'stmts': z.array(z.lazy(() => statementSchema))}),
]);

Linked list

late final ZemaSchema<dynamic, dynamic> nodeSchema;

nodeSchema = z.object({
  'value': z.string(),
  'next':  z.lazy(() => nodeSchema).nullable(),
});

nodeSchema.parse({
  'value': 'a',
  'next': {
    'value': 'b',
    'next': null,
  },
});

Type parameters

For recursive schemas, Dart cannot infer I and O, the type is inherently dynamic. Use dynamic explicitly:

// Type annotation required — inference fails for recursive types
late final ZemaSchema<dynamic, dynamic> schema;
schema = z.object({...});

If the schema has a fixed, known output type, you can annotate the z.lazy() call directly:

// Non-recursive lazy — type can be inferred from fn's return
final schema = z.lazy<Map<String, dynamic>, Map<String, dynamic>>(
  () => z.object({'id': z.integer()}),
);

Caching

The factory function is called exactly once, on the first safeParse. The resolved schema is cached and reused for every subsequent parse. There is no per-call overhead after the first resolution.


Async limitation

LazySchema does not override safeParseAsync. The base implementation calls safeParse synchronously. If the inner schema contains .refineAsync() rules, they will not run when parsing through a lazy wrapper, even if you call safeParseAsync(). Use .refineAsync() outside the lazy boundary if async validation is needed.


API reference

ZemaSchema<I, O> lazy<I, O>(ZemaSchema<I, O> Function() fn)
ParameterTypeDescription
fnZemaSchema<I, O> Function()Factory called once on first parse. Result is cached.
ReturnsZemaSchema<I, O>Schema that delegates to the lazily-resolved inner schema.
Copyright © 2026