Branch-routed forms with one definition, both sides
form-graph is a contract engine for forms whose shape depends on their own values — selecting a workflow changes which fields exist, with defaults, per-branch memory, and coupling rules. You declare the fields as a graph; TypeScript infers the discriminated union from it; the same definition IS the client store and parses raw input on the server.
import { z } from 'zod';
import { defineGraph, branch } from 'form-graph';
import { enumOf, slider } from 'form-graph/defs';
const create = defineGraph<{ maxSteps: number }>()
.field('prompt', {
input: z.string().optional(),
output: z.string().min(1, 'Prompt is required'),
default: '',
})
.field('steps', ({ _ext }) => slider({ min: 1, max: _ext.maxSteps, default: 25 }));
const upscale = defineGraph<{ maxSteps: number }>()
.field('scale', slider({ min: 2, max: 4, default: 2 }));
// the discriminator field routes between the graphs — the union is inferred
export const form = defineGraph()
.field('workflow', WORKFLOW)
.use(branch('workflow', [[['create'], create], [['upscale'], upscale]] as const));
// Client: a live store with per-field subscriptions and persistent intent.
const store = form.createStore({ ext: { maxSteps: 50 } });
// Server: the same pipeline over raw input. No second schema.
const result = form.parse(rawBody, { maxSteps: 50 }); What it does that form libraries don't
- The union is inferred, not annotated. Each branch graph resolves to a different shape; consumers narrow on the discriminant like any TypeScript union. Cost scales with branch width, not nesting depth.
- Identical output client and server.
parse()runs the same resolve → validate pipeline the store runs, so what the UI shows is what the server accepts. - Scoped, persistent intent. User choices are remembered per scope (per-branch, per-group) and survive branch switches — return to a branch and your values are back.
- Lenient boundaries, strict output. Dual schemas per field: stored/remixed/raw values parse leniently and fall back to defaults; submit validates strictly, with every issue and its path.
- Framework-agnostic by construction. All semantics live in the core store; the React and Svelte bindings are each a thin bridge over the same per-field subscriptions.
Why this exists, the docs, or the live demo — a real generation-form port with 7 workflows and 8 ecosystems, parsed server-side by a SvelteKit form action.