Definitions
A field definition is one object carrying everything the field is. Write it inline, or let a helper build it:
interface FieldDef<T, M> {
output: SchemaLike<T>; // strict: submit / output() / server parse
input?: SchemaLike<unknown>; // lenient: storage, URLs, patches — undefined = fall to default
default?: T | (() => T);
meta?: M | ((value: T) => M); // UI props; fn form for value-derived meta
scope?: Scope; // where this field's memory lives
refine?(output): SchemaLike<T>; // per-pass narrowing — judged with the output schema
correct?: (value: T) => { value: T; reason: string; detail?: object } | undefined;
coerce?: (raw: unknown) => T; // fast path for trusted writes
toOutput?: (value: T) => unknown; // submission projection
emit?: false | string; // wire disposition: rename, or keep off the wire
} Keep the schema type: satisfies, never a type annotation
The helpers return their CONCRETE schema types (textOf().output is a ZodString, not a bare SchemaLike), so the spread-and-narrow
pattern above composes without casts. Hand-written definitions keep that property only if
you let inference carry the type — annotating erases it:
const GOOD = { output: z.string().email(), default: '' } satisfies FieldDef<string>;
GOOD.output.refine(...) // ZodString survives
const BAD: FieldDef<string> = { output: z.string().email(), default: '' };
BAD.output.refine(...) // error: SchemaLike<string> has no .refine defineDef({...}) packages the same rule as a helper: an identity
wrapper that infers T from the definition's own output schema
and validates default/correct/meta against it, while
preserving every concrete schema type — use it for def-factory returns, where a return-type
annotation is the tempting (and silently erasing) form. One difference from satisfies: callback params get no contextual type, so annotate them; a wrong
annotation is still rejected.
input is optional — and live typing never touches it
Session edits (set()) write TRUSTED intent: a half-typed invalid value is held
and rejected only at submit (under the default revalidate: 'submit'; with 'touched', a field the user has written is judged live — see The store), with
or without an input schema. The input schema guards only
UNTRUSTED boundaries — storage reload, raw server input, URL params. Omit it, and those
boundaries parse with the OUTPUT schema, leniently: an invalid stored value falls to the
default, with the error recorded. That is the right behavior for most fields. Declare input yourself for its real jobs: coercion (z.coerce), key
migration, and restoring INVALID persisted drafts across reload (long text — textOf does this for you, and takes an output override for
formats: textOf({ output: z.string().email('…') })).
The helpers
slider, enumOf, textOf, boolOf build the
common definitions. Call them anywhere — including inside a per-pass definition function —
because their SCHEMAS are cached automatically, keyed on the exact values the schemas are
built from. The inputs are the dependency array: nothing to declare, staleness impossible.
import { slider, enumOf, textOf, boolOf } from 'form-graph/defs';
slider({ min: 1, max: 50, step: 1, default: 25, presets: [...] })
// meta: { min, max, step, presets? } — presets are meta, outside the schema cache key
// lenient input REPAIRS: out-of-range snaps into bounds; only a non-number defaults
enumOf({
options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B', disabled: true }],
default: 'a',
gate: { b: cond && 'reason' }, // availability: disabled option + correction, one declaration
})
// value type: 'a' | 'b' — inferred, numeric enums included
textOf({ maxLength: 200, required: true })
boolOf({ default: true }) Conditional anything
Presets, step, bounds, options, messages — a definition function just computes them. Two cache entries exist for the two step values below, built once each:
.field('steps', (c) => slider({
min: 1, max: 50,
step: c.draft ? 4 : 1,
presets: c.draft ? DRAFT_PRESETS : PRESETS,
})) Custom zod, inline
The output schema is yours — but per-pass NARROWING goes through refine, not a
rebuilt output. Rebuilding output inline each pass is exactly what
the store's codec-churn warning names (it checks output identity too):
.field('hazmatClass', (c) => ({
...HAZMAT, // cached/hoisted base — its schemas never rebuild
refine: (output) => output.refine((v) => !(v === '1.4' && c.service === 'air'), {
message: 'Class 1.4 explosives cannot ship by air',
}),
})) Spread-and-override output only for a genuinely DIFFERENT schema, hoisted or
cached once — see "Per-pass narrowing: refine" below for the full semantics.
The performance model, measured
- zod schema construction: ~25–35µs per definition — the one thing worth caching;
- a definition object: ordinary allocation, free;
- a full generation-scale graph (LTX): ~20µs per keystroke for resolve + diff — per-pass definitions measured FASTER than statically-hoisted ones.
So: use helpers freely, spread-and-override for custom schemas — and give an app's OWN def
factories the same treatment the built-ins get with cachedFactory:
import { cachedFactory } from 'form-graph';
// Built once per distinct config, reused on every later pass.
export const durationDef = cachedFactory((cfg: { min: number; max: number }) => ({
input: z.coerce.number().optional(),
output: z.number().min(cfg.min).max(cfg.max),
default: cfg.min,
meta: cfg,
})); defFamily and cachedFactory are ONE mechanism — build once per
distinct args, JSON as the key — behind two signatures: defFamily(build) takes
function-free args (primitives or plain config objects) and keys on all of them; cachedFactory(build, keyOf?) adds a custom key extractor for configs where some
parts don't shape the schemas. Use whichever reads better; they share an implementation and
may fold into one export in a future minor. For a schema built from LIVE ctx (a gate set, a
discriminant), key on exactly the values the schema reads — that dependency is something
only the author knows. A factory that builds schemas inline on every pass is what the
store's codec-churn warning points at; it compares SCHEMA identity (input AND output) across
passes, so cached factories and hoisted defs pass clean while genuine per-pass construction
is named.
Never cache a schema that closes over per-request state. A transform that
captures, say, a request-scoped collector from ext and gets cached will keep writing into the
FIRST request's collector forever. Cache only what the config determines; take per-request
effects through correct on the (uncached, cheap) def object instead.
Per-pass narrowing: refine
When only the STRICTNESS of a field varies per pass — required unless images are attached, a
ceiling that depends on a sibling — don't rebuild output; keep the cached base
and narrow it with refine, which builds one small wrapper per pass:
.field('prompt', ({ images }) => ({
...PROMPT, // cached/hoisted base def
refine: images?.length ? undefined : (output) => output.min(1, 'Prompt is required'),
})) A failing refine keeps the value in place and fails submit/parse; the error reaches the snapshot at validate() — like the output schema it narrows, refine
judges at submit by default, so a pristine required field doesn't scold before the user ever
acts (under revalidate: 'touched', fields the user has WRITTEN are judged on
every recompute — see The store; pristine fields stay quiet either way). Once surfaced, the
error lifts on the first pass whose refinement passes again. Refusal for the user to
resolve, where correct is substitution the form resolves itself.
Registry
graph.defs is the registry: TYPE-complete (every key, function-defined fields
included), which is what gives typedFields, <Field> and the
typed React controllers each key's exact value and meta types with no annotations.