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
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;