Getting started
Install the package and your framework binding's peer (the core has no framework dependency; zod is optional — any Standard Schema library works):
pnpm add form-graph zod
1. A field is one definition
Everything about a field lives in one object: a lenient input schema for
untrusted boundaries (storage, URLs, raw server input), a strict output schema
for submit, a default, UI meta, and — when the field needs them — its memory scope and
correction policy. The helpers build common definitions and cache their schemas
automatically:
import { defineGraph } from 'form-graph';
import { slider, enumOf, textOf, boolOf } from 'form-graph/defs';
import { z } from 'zod';
const graph = defineGraph()
.field('mode', enumOf({
options: [
{ value: 'create', label: 'Create' },
{ value: 'upscale', label: 'Upscale' },
],
default: 'create',
}))
.field('prompt', {
input: z.string().optional(),
output: z.string().min(1, 'Prompt is required'), // full zod, yours
default: '',
})
// CONDITIONAL fields are FUNCTIONS of one bag: the fields declared ABOVE
// (destructure exactly what you read — a later field is a compile error)
// plus the external context under _ext. Return null for "absent this pass".
.field('steps', ({ mode }) => (mode === 'create' ? slider({ min: 1, max: 50, default: 25 }) : null))
.field('scale', ({ mode }) => (mode === 'upscale' ? slider({ min: 2, max: 4, default: 2 }) : null))
.computed('summary', ({ mode, prompt }) => `${mode} · ${prompt.length} chars`); 2. Use it
There is no mounting step — the graph IS the form. The runtime lives on the definition:
// Client: a live store — per-field subscriptions, persistent scoped memory. const store = graph.createStore(); // Server: the same pipeline over raw input. One behavior, both sides. const result = graph.parse(rawBody);
External context (limits, permissions — facts the form reads but the user doesn't edit) is the graph's type parameter, and every definition function receives it:
const g = defineGraph<{ maxSteps: number }>()
.field('steps', ({ _ext }) => slider({ min: 1, max: _ext.maxSteps, default: 25 }));
const store = g.createStore({ ext: { maxSteps: 50 } });
store.setExt({ maxSteps: 30 }); // the whole form re-resolves 3. Render it
The graph's registry types every key exactly — conditional fields included — so the bindings need no annotations (see the Svelte and React pages):
<Field {store} name="steps">
{#snippet children(snap, setValue)}
<input type="range" min={snap.meta.min} max={snap.meta.max}
value={snap.value} oninput={(e) => setValue(Number(e.currentTarget.value))} />
{/snippet}
</Field> From here: Core concepts for the ideas underneath (intent, scoped memory, corrections), Definitions for the full anatomy, and the demos — every one shows its own source.