form-graph

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.