Lazy Schema
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)
| Parameter | Type | Description |
|---|---|---|
fn | ZemaSchema<I, O> Function() | Factory called once on first parse. Result is cached. |
| Returns | ZemaSchema<I, O> | Schema that delegates to the lazily-resolved inner schema. |