form-graph

Checkout

Rung four: composition. Contact, address and payment are defined in separate modules that know nothing about each other — each is an ordinary graph (contact carries its own rules). The parent mounts them with .use and owns only what no section can know alone: billing mirrors shipping unless unchecked (the address section is instantiated twice, under different keys, each with its own memory), and invoicing is passed down to the payment section as isBusiness through its Ext at the mount point, from the contact section's answer. There is no subform machinery — a resolver is a function, and functions compose.

contact — contact.ts

shipping — address.ts

shippingCountry

billing — address.ts, second instance

payment — payment.ts

paymentMethod
items
$120.00
shipping (US)
$5.00
payment fee
$2.40
total
$127.40
Try: check isBusiness, pick Invoice, then uncheck isBusiness — the parent corrects the payment section. Uncheck billingSameAsShipping, fill billing, re-check, uncheck again: the second instance kept its own memory.
view source — checkout-form.ts (40 lines)
import { defineGraph } from '$lib/index.js';
import { boolOf } from '$lib/defs/index.js';
import { contact } from './contact.js';
import { withAddress } from './address.js';
import { payment, FEE_RATE } from './payment.js';

// The PARENT: sections are ordinary graphs mounted into ONE chain with .use,
// so cross-section facts are c reads — billing mirrors shipping via a field
// between two mounts of the SAME section, and the payment graph's need for
// `isBusiness` is satisfied by what contact declared three mounts up. The
// contact graph's own rules ride in through the mount; nothing is re-imported.

const ITEM_TOTAL = 120;
const SHIPPING_COST = { US: 5, DE: 12, JP: 18 } as const;

export const checkoutForm = defineGraph()
  .use(contact)
  .use((g) => withAddress(g, 'shipping'))
  .field('billingSameAsShipping', boolOf({ default: true }))
  .use((g) =>
    withAddress(
      g,
      'billing',
      (c) => !(c as { billingSameAsShipping: boolean }).billingSameAsShipping
    )
  )
  .use(payment)
  .computed('shipping', (c) => ({
    street: c.shippingStreet,
    city: c.shippingCity,
    country: c.shippingCountry,
  }))
  .computed('billing', (c) =>
    c.billingSameAsShipping
      ? c.shipping
      : { street: c.billingStreet!, city: c.billingCity!, country: c.billingCountry! }
  )
  .computed('shippingCost', (c) => SHIPPING_COST[c.shippingCountry])
  .computed('paymentFee', (c) => Math.round(ITEM_TOTAL * FEE_RATE[c.paymentMethod] * 100) / 100)
  .computed('total', (c) => ITEM_TOTAL + c.shippingCost + c.paymentFee);
view source — contact.ts (26 lines)
import { z } from 'zod';
import { defineGraph } from '$lib/index.js';
import { boolOf, textOf } from '$lib/defs/index.js';

// A reusable section is just a GRAPH: define it standalone, mount it into a
// parent chain with `.use(contact)`. Its fields, registry, and effects join
// the parent; it knows nothing about the form it lands in.

export const contact = defineGraph()
  .field('email', textOf({ output: z.string().email('A valid email is required') }))
  .field('isBusiness', boolOf())
  .field('company', ({ isBusiness }) =>
    isBusiness ? textOf({ output: z.string().min(1, 'Company name is required') }) : null
  )
  .field('vatId', ({ isBusiness }) =>
    isBusiness
      ? textOf({ output: z.string().regex(/^[A-Z]{2}[0-9A-Z]{6,12}$/, 'VAT id looks like DE812526315') })
      : null
  )
  // Section-owned coupling, a plain map keyed by the trigger field: switching
  // OFF business clears the business-only intent, so stale company data can't
  // linger and resurface. It rides the mount — the parent never imports it.
  .effect({
    isBusiness: (value) =>
      value === false ? { company: undefined, vatId: undefined } : undefined,
  });
view source — address.ts (57 lines)
import { z } from 'zod';
import { type AnyFieldDef, type FieldDef, type Graph } from '$lib/index.js';
import { enumOf, textOf } from '$lib/defs/index.js';

// The same section mounted more than once: field keys must be unique
// form-wide, so the caller names a PREFIX. Without `when` the fields are
// unconditional (required keys); with `when` the whole mount is conditional
// (optional keys) — the billing block behind the same-as-shipping toggle.

const TEXT = (message: string): FieldDef<string> =>
  textOf({ output: z.string().min(1, message) });

const COUNTRY = enumOf({
  options: [
    { value: 'US', label: 'United States' },
    { value: 'DE', label: 'Germany' },
    { value: 'JP', label: 'Japan' },
  ],
  default: 'US',
});

type Country = 'US' | 'DE' | 'JP';

export function withAddress<C extends object, D extends Record<string, AnyFieldDef>, P extends string>(
  g: Graph<C, void, D>,
  prefix: P
): Graph<
  C & Record<`${P}Street` | `${P}City`, string> & Record<`${P}Country`, Country>,
  void,
  D & Record<`${P}Street` | `${P}City`, FieldDef<string>> & Record<`${P}Country`, typeof COUNTRY>
>;
export function withAddress<C extends object, D extends Record<string, AnyFieldDef>, P extends string>(
  g: Graph<C, void, D>,
  prefix: P,
  when: (c: C) => boolean
): Graph<
  C & Partial<Record<`${P}Street` | `${P}City`, string> & Record<`${P}Country`, Country>>,
  void,
  D & Record<`${P}Street` | `${P}City`, FieldDef<string>> & Record<`${P}Country`, typeof COUNTRY>
>;
export function withAddress(
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  g: Graph<any, void, any>,
  prefix: string,
  when?: (c: object) => boolean
) {
  if (when === undefined) {
    return g
      .field(`${prefix}Street`, TEXT('Street is required'))
      .field(`${prefix}City`, TEXT('City is required'))
      .field(`${prefix}Country`, COUNTRY);
  }
  return g
    .field(`${prefix}Street`, (c) => (when(c) ? TEXT('Street is required') : null))
    .field(`${prefix}City`, (c) => (when(c) ? TEXT('City is required') : null))
    .field(`${prefix}Country`, (c) => (when(c) ? COUNTRY : null));
}
view source — payment.ts (49 lines)
import { z } from 'zod';
import { defineGraph } from '$lib/index.js';
import { enumOf, textOf } from '$lib/defs/index.js';

// Payment section: an ordinary graph. Its Ext declares what it NEEDS from
// upstream — `isBusiness` from the contact section — and the mount point
// (`.use(payment)`) satisfies it from the parent's c-so-far. Its own fields
// stay c reads, exactly as in any graph.

export const payment = defineGraph<{ isBusiness: boolean }>()
  .field('paymentMethod', ({ _ext }) =>
    enumOf({
      options: [
        { value: 'card', label: 'Card' },
        { value: 'paypal', label: 'PayPal' },
        { value: 'invoice', label: 'Invoice' },
      ],
      default: 'card',
      // One declaration: disabled option AND correction, before branching,
      // so method and mounted fields can never disagree.
      gate: { invoice: !_ext.isBusiness && 'invoice_requires_business' },
    })
  )
  .field('cardNumber', ({ paymentMethod }) =>
    paymentMethod === 'card'
      ? textOf({
          output: z
            .string()
            .transform((s) => s.replace(/\s/g, ''))
            .pipe(z.string().regex(/^\d{15,16}$/, '15 or 16 digits')),
        })
      : null
  )
  .field('cardExpiry', ({ paymentMethod }) =>
    paymentMethod === 'card'
      ? textOf({
          output: z.string().regex(/^(0[1-9]|1[0-2])\/\d{2}$/, 'MM/YY'),
        })
      : null
  )
  .field('poNumber', ({ paymentMethod }) =>
    paymentMethod === 'invoice'
      ? textOf({
          output: z.string().min(1, 'PO number is required for invoicing'),
        })
      : null
  );

export const FEE_RATE = { card: 0.02, paypal: 0.03, invoice: 0 } as const;