Server parsing
form.parse(raw, ext) is the server entry point, and it is the same pipeline the
client walks: boundary input schemas → resolve → strict output validation. Pure — no store, no shared
state.
const result = form.parse(rawBody, ext);
if (!result.success) {
// result.errors: Record<string, FieldError>
// FieldError = { message, code, issues: SchemaIssue[] }
// every issue survives, with path relative to the field —
// issues[1].path === [2, 'id'] means row 2's id failed.
return badRequest(result.errors);
}
result.data; // the discriminated union, strict-validated
result.computedKeys; // which keys were derived, not user input
result.notes; // substitution notes (see below) What to send: store.getIntent() is the wire format
The client payload is the store's own intent record — store.getIntent() — sent as
JSON. parse reads it exactly the way the store reads it: a scoped address
(steps@flux) wins for the field it names, a bare key serves any field, a
list's membership rides as its own entry (runs: ['s0','a1b2']) with element
values under path keys (runs[s0].epochs). Every entry is treated as boundary
input server-side — lenient input schemas run regardless of how the client stored it.
// client
await fetch('/api/submit', { body: JSON.stringify(store.getIntent()) });
// server — the identical pipeline, from the identical record
const result = form.parse(raw, ext); Plain key-addressed raw ({ prompt, steps } from any external caller) parses
too — the scoped-address form is what makes the client's own submit lossless, including
per-branch memory the user isn't currently looking at.
SvelteKit form action
// +page.server.ts
export const actions = {
default: async ({ request }) => {
const raw = JSON.parse(String((await request.formData()).get('state')));
const result = form.parse(raw, ext);
if (!result.success) return fail(400, { errors: result.errors });
return { data: result.data };
},
}; The live demo makes this same parse() call — the site
is statically hosted, so it runs in the page instead of a form action.
Substitution notes
When a correct policy (the def's correct property) replaces a value — a retired model swapped for the default, a quantity
clamped to a limit — the field can attach a note with a machine-readable reason. Notes ride on
the parse result (on failures too), so the server can log, bill, or refuse based on why a value changed rather than diffing blindly.
for (const note of result.notes ?? []) {
// { key, reason: 'locked_default' | 'ecosystem_mismatch' | ..., detail }
} Partial parse
form.parsePartial(raw, ext) returns every valid field plus the errors — for cost
estimation and other best-effort reads where one bad field shouldn't void the rest.
Extracting types
Handlers downstream of parse shouldn't re-declare what the graph already knows.
Four utility types extract it from the graph value itself:
import type { InferData, InferState, InferArm, InferLooseData } from 'form-graph';
type Data = InferData<typeof form>; // the parsed discriminated union (result.data)
type State = InferState<typeof form>; // the resolved state snapshot
// One arm of the union, selected by a discriminator value. Subset-matching,
// so it works when a branch pair groups several keys into one arm:
type UpscaleData = InferArm<typeof form, 'mode', 'upscale'>;
// Every field optional and unknown — the shape of raw/stored input:
type Loose = InferLooseData<typeof form>; InferArm distributes over a union of discriminator values, so InferArm<G, 'mode', 'a' | 'b'> is the union of both arms. Type a per-branch
handler as a function of its arm and the compiler holds the wire contract for you.
Introspection
Because branches are plain code, enumeration is execution: enumerateBranches walks
the reachable branch space, hasField / whereFieldExists answer
"does this field exist under these pins", and fieldMeta resolves one field's
metadata under pinned values — all without a store.