form-graph

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.