The graph
defineGraph<Ext>() starts a chain of field definitions. Each entry is the
whole field; each definition function receives ONE bag — the fields declared above it,
spread at top level so you destructure exactly what you read, plus the external context
under the reserved key _ext:
const graph = defineGraph<Ext>()
.field('mode', enumOf({ ... })) // static definition
.field('steps', ({ mode, _ext }) => // conditional: a function
mode === 'create' ? slider({ min: 1, max: _ext.maxSteps }) : null)
.computed('price', ({ size, toppings }) => ...) // derived, read-only key
.effect(coupling); // a rules unit riding the graph Existence: return null
A definition function returning null means the field does not exist this pass —
no record, no snapshot, gone from the page (the bindings' <Field> renders
nothing). The key types optional in c and in the state. Intent survives
deactivation, so the value returns when the field does.
Dependencies are the chain
c contains exactly the fields declared above — statically. You cannot
reference a later field, a misspelled field, or a field from another branch; the compiler
enforces what a dependency graph would, with no dependency graph. Order is visible, cycles
are unrepresentable.
Computeds on the wire: emit
A computed is form-internal by default. emit: '<name>' puts its value on
the parse output under that name — and if a FIELD already carries the name, the field is
implicitly shadowed off the wire: the selection stays in the form (and in storage), the
derived value is what submits. That is the clean shape for "two facts wearing one key"
(below): the stored selection and the derived backend value each keep their own home.
.field('ecosystem', ECOSYSTEM) // the user's SELECTION — form + storage
.computed('effectiveEcosystem',
({ model, _ext }) => deriveFrom(model) ?? _ext.ecosystem,
{ emit: 'ecosystem' }) // the WIRE value — what parse() emits
// two computeds emitting one name throw on validate/parse; emit: false is the explicit
// form-only marker where the default would read as an oversight Composition
Graphs are immutable values. Everything composes with the language:
// shared prefix: keep chaining from a common base
const base = defineGraph<Ext>().field('images', ...).field('model', ...);
const v23 = base.computed('version', () => 'v23' as const).field('resolution', ...);
// shared section: another graph, mounted with .use (see Reuse)
const promptBlock = defineGraph<Needs>().field('prompt', ...).field('negativePrompt', ...);
const full = v23.use(promptBlock);
// the same section twice, under key prefixes (a function via .use — see the checkout demo)
g.use((g) => withAddress(g, 'shipping')); Alternative shapes: hubs
The one thing a chain deliberately does not express: branches that produce DIFFERENT shapes with a discriminated union between them (a hub's destinations, a generator's version subgraphs). One combinator structures that hub, in two forms — every branch is discriminated; the only question is whether the key already exists or is derived here. KEYED: the discriminator is a field (or computed) declared upstream, and the members table is the switch as data — one entry per member graph, however many key values it serves, the key literals typing the arms:
const publish = defineGraph()
.field('destination', DESTINATION)
.use(branch('destination', [
[['s3'], s3Graph], [['email'], emailGraph], [['webhook'], webhookGraph],
]));
// State = { destination: 's3' } & S3Ctx | { destination: 'email' } & EmailCtx | ...
// Extract<State, { destination: 's3' }> is exactly the s3 shape.
// A grouped pair is ONE arm with a literal-union key:
// [['SD1', 'SDXL'], sd] -> { destination: 'SD1' | 'SDXL' } & SdCtx TAGGED: the key is DERIVED from external context and stamped into state as a computed — the shape of a version-family form, where the ecosystem picks the subgraph:
export const wan = branch(
'wanVersion', // tag: the picked MEMBER KEY lands
(ext: WanExt) => versionOf(ext.ecosystem), // in state under this key
{ 'v2.1': v21, 'v2.2': v22, 'v2.5': v25 }
).effect(({ patch, state, next }) => { ... }); // the family's coupling, inline
// Extract<State, { wanVersion: 'v2.5' }> is exactly that member's shape —
// no member re-declares which member it is. Pass { emit: false } to keep the
// tag in state (UI and rules read it) but off the parsed data's wire. There is deliberately no untagged form: a pick function's control flow is invisible to the
type system, so an untagged branch could not type its arms. A dispatch that looks untagged
is either keyed (the discriminator is a field) or tagged with emit: false (derived but private).
Either way the hub is the same shape a graph is: defs holds every member's
registry merged (type-complete, so the bindings know every key), effects holds
every member's rule units plus the hub's own, and resolve returns the
discriminated union (record-less hubs merge the registry at the TYPE level only — their
runtime `defs` object stays empty, which nothing reads: resolution always passes each
field's freshly computed def).
Rule of thumb: null for fields that come and go within one shape; a hub
between different shapes. Within a graph, conditional keys are optional; between graphs, the
union discriminates.
“Mutually dependent” fields are a modeling smell
A graph resolves once, in declaration order — a cycle is unrepresentable ON PURPOSE. When two fields seem to need each other, the domain almost always contains a hidden DERIVED value conflated into one of them (a stored “ecosystem” that is really user selection + a backend target computed from resolution). Split them: keep the SELECTION as the field, and derive the target where its inputs already exist — a later definition function, a computed, or the submission boundary. The dependency order was the design telling you the field was two facts wearing one key.
The runtime is on the definition
const store = graph.createStore(); // client const result = graph.parse(rawBody); // server — the same pipeline
Hubs carry the same runtime: publish.createStore(). Underneath, every entry
compiles onto a resolver engine; nothing about intent, scoping, corrections, the diff, or
server parse is graph-specific. The engine itself is not exported — every form,
the generation-scale hub included, is expressible as a graph (proven by the differential
parity suite), so graphs are the only authoring surface.