form-graph

Reusable definitions

There is no reuse machinery — definitions and graphs are ordinary values, so reuse is ordinary code. Three patterns cover everything the demos and the generation-scale ports needed:

1. Definition factories

A function returning a definition, parameterized by whatever varies:

const toppingsDef = (budget: number) => ({
  input: z.array(z.string()).optional(),
  output: z.array(z.string()),
  default: [] as string[],
  meta: { budget },
  correct: (picked: string[]) => trimToBudget(picked, budget),
});

.field('toppings', (c) => toppingsDef(SIZES[c.size].budget))

Helpers used inside a factory keep their automatic schema caching; a factory that hand-builds zod pays construction per distinct call — wrap the schema part in defFamily if it's ever hot.

2. Sections are just graphs, mounted with .use()

There is no section concept to learn: a reusable section IS a defineGraph. If it needs facts from wherever it gets mounted, it declares them as its Ext — at the mount point, the parent's ext with the c-so-far merged over it is what the child receives, so a need is satisfied by a prior field or by the parent's own c._ext. Its fields, registry, and effects join the chain.

export const contact = defineGraph()
  .field('email', EMAIL)
  .field('isBusiness', boolOf())
  .field('company', (c) => (c.isBusiness ? COMPANY : null))
  .effect(contactRules);   // the section's own coupling rides the mount

// another graph NEEDS what contact declares — that's its Ext:
export const payment = defineGraph<{ isBusiness: boolean }>()
  .field('paymentMethod', (c) => enumOf({
    options: METHODS,
    default: 'card',
    gate: { invoice: !c._ext.isBusiness && 'invoice_requires_business' },
  }));

// mounting is chain-linear; payment's need is met by contact's field:
const graph = defineGraph()
  .use(contact)
  .field('billingSameAsShipping', boolOf({ default: true }))
  .use(payment);

A missing REQUIRED need is a type error at the mount point naming the key; optional needs read as undefined when nothing upstream declares them. Hubs mount the same way — .use() accepts anything graph-shaped.

3. The same section, mounted more than once

Field keys are unique form-wide, so mounting one section twice needs a key PREFIX — a transform a standalone graph can't express. For that, .use() also takes a plain function (use(fn) is fn(g)), and an optional when makes the whole mount conditional (keys go optional):

g.use((g) => withAddress(g, 'shipping'));                       // required keys
g.use((g) => withAddress(g, 'billing', (c) => !c.billingSameAsShipping));

The checkout demo composes all three patterns into one chain; the LTX/Wan generation ports compose shared prefixes and suffixes across five version graphs. Effects that belong to a section attach with .effect(unit) and ride into the form's reconcile via graph.effects.