form-graph

Why form-graph

Complex forms without the spaghetti

This library started from a simple frustration: building complex forms shouldn’t mean managing a mountain of conditionals. One kind of form breaks every general-purpose form tool — a form whose shape depends on its own values. Pick a workflow and the ecosystems change; pick an ecosystem and the fields change; pick a model and it can change the workflow right back.

The whole idea — the engine's resolver, annotated (authored via defineGraph/branch)
resolve: (f, ext) => {
const workflow = f.field('workflow', WORKFLOW);
const base = { workflow, prompt: f.field('prompt', PROMPT) };
switch (workflow) {
case 'upscale':
return { ...base, scale: f.field('scale', SCALE) };
default:
return { ...base, steps: f.field('steps', STEPS) };
}
}

Each section below is a problem that shape actually produced in production — and the mechanism here that answers it.

The problem

The conditionals end up everywhere

With a general-purpose form library, a value-dependent form has no single home for its shape. The branching leaks into every layer as its own copy of the same conditional: a watch() in this component to hide a field, an if in the validator to skip it, an effect over there to reset it, another guard in the submit handler because the hidden field’s value is still in the state object. Fifty fields deep, no one can answer “what does this form look like when mode is X?” without reading all of it.

The mechanism

The shape has exactly one home — the resolver. It declares which fields exist for the current values, and the branch is a switch statement, readable top to bottom. Everything else derives from that single declaration: the UI renders the fields the resolver returned, the output contains only those fields, validation runs only over them, and the types narrow to match. The conditionals don’t disappear — they collapse into the one place where they read as structure.

The problem

The type system gives out before the form does

Model a branching form as composed schemas — discriminated unions of zod objects, graphs merged into graphs — and the compiler pays for nesting. Every merge wraps the previous type one level deeper, and at real scale (dozens of workflows × ecosystems) TypeScript stops with “type instantiation is excessively deep” (TS2589). The type that was the whole point becomes the obstacle.

The mechanism

Branches aren’t modeled in the type system — they’re modeled in code, and the type system observes them. Each switch arm returns a different object literal; the inferred return type IS the discriminated union. Cost scales with the number of branches (width), not how they compose (depth). Adding branch fifty compiles like adding branch five — measured, not hoped.

The problem

Two ends of one contract, drifting

The usual split — a form library on the client, “also run zod” on the server — is two implementations of one contract, and they drift. A default added on one side, a migration applied on the other, an aspect-ratio table trimmed differently: each drift is invisible until a submission the UI considered valid is rejected — or accepted with different values.

The mechanism

There is one definition, and form.parse(raw, ext) runs the exact pipeline the client store runs — boundary input schemas, resolution, rules, strict output validation. The server doesn’t approximate the form; it executes it. Building this library, a differential harness ran the same inputs through a legacy graph and this engine — the drifts it caught (a default only an effect applied; a table that resolved ’16:9’ to ’3:2’) are exactly the class this design deletes.

The problem

Branch switches destroy the user’s work

A user tunes steps and cfg for one model family, tries another, comes back — and everything reset, because form state only held the fields that currently exist. Teams patch this with ad-hoc localStorage keys per field, which become an undocumented persistence format that UI code reads raw.

The mechanism

State is split from intent. Intent is everything the user ever chose, keyed by address, never deleted when a branch deactivates; visible state is a pure function of it. A field can declare a scope, so its memory is per-branch or per-group (steps@flux vs steps@sd) — return to a branch and its values return with it. The persisted format is the intent record itself, with supported readers instead of raw key parsing.

The problem

One stale stored value wedges the whole form

Anything that persists choices eventually reloads a value the current config no longer accepts — a retired model id, a removed workflow key, a corrupt blob. Validate strictly on load and the form errors before the user touches it; skip validation and garbage flows into submissions.

The mechanism

Every value knows where it came from. UI writes are trusted and stored verbatim. Boundary values — storage, URLs, remixes, raw server input — run a separate lenient input schema, lazily, falling back to the default on failure. The strict output schema runs only on demand by default: submit, output(), server parse (opt into revalidate: 'touched' for live errors on written fields, ~6µs worst case). A corrupt value can cost one field’s memory; it can never wedge the form. And since nothing schema-shaped runs during typing by default, keystrokes stay flat at any form size.

The problem

Silent corrections you can’t audit

Real forms correct user values: clamp a quantity to the account’s limit, substitute a retired checkpoint, force the locked model for a draft workflow. Do it silently and the server can’t tell a correction from a tampered request; error instead and you punish users for config changes they never saw.

The mechanism

Two first-class reactions, split by whose problem it is. A definition’s correct policy replaces the value — a visible statement in the definition, with a machine-readable reason (locked_default, ecosystem_mismatch, …) riding on every parse result, failures included. refine narrows the output schema in zod’s own vocabulary and refuses at submit, with a per-field error. The server doesn’t diff blindly; it reads why a value moved.

The problem

Couplings become effect soup

“Selecting the draft workflow forces the draft model, and selecting the draft model forces the draft workflow” — written as reactive effects, that’s two watchers with mutual guards, ordering dependencies on the store, and a cycle waiting for the guard someone deletes.

The mechanism

Couplings are plain rule maps on .effect, keyed by the field whose change triggers them. Rules rewrite the patch before resolution, in one ordered pass per set(), each rule at most once, no rewind — cycles aren’t detected, they’re unrepresentable. The driver is whichever field the user actually touched, so the two directions coexist without chasing each other.

The problem

Recompute-everything re-renders everything

Deriving the whole form on every keystroke is the simplest correct model — and naively it means every control re-renders on every keystroke, which is exactly why large forms lag. The usual escape is incremental recomputation, which trades the simplicity away for dependency-tracking machinery.

The mechanism

Keep the full recompute; fix the notification. The snapshot diff is reference-preserving — a field whose data didn’t change keeps its exact object identity, and its per-field subscribers never fire. Typing in the prompt recomputes everything and wakes one control. The same reference guarantee is what lets Svelte and Vue reactivity work unmodified.

The problem

Welded to one UI framework

When the contract engine lives inside React hooks, the server can’t run it, tests need a DOM, and a second frontend means a rewrite.

The mechanism

Every semantic — resolution, rules, intent, diffing, validation — lives in a framework-free core with a subscribe/snapshot store. The React and Svelte bindings are each a few dozen lines bridging that store into their reactivity system; the demos run the same definition SvelteKit-server-side that a React app would run in the browser.

Scope

What this is not

form-graph is a contract engine, not a UI kit: no rendered components, no layout, no step navigation chrome. The form-shaped mechanics ARE here — collections (list()), wizard-step gates (scoped validate), dirty tracking, focus-on-error — but a form whose shape is static and whose fields are independent is well served by the established form libraries, and pairing one of them with a single form-graph field is a supported pattern, not a workaround. The line: if a feature changes what the parsed output means or how a value is remembered, it belongs here; if its subject is how the UI looks, it doesn’t.

Getting started →