Type safety
Types start in the event catalog and flow through module reducers, effects, projections, and React hooks via EventsOf and LoomsRegister.
The chain#
- A Zod-backed (or Effect / Standard Schema) event catalog fixes event names and payloads.
defineModulescope narrowseventinthread.step/projection.reduceand checks effect outputs.- Typed
invoke(effectDef, input)checks effect input. ProjectionDefinition<S>carries projection state intolooms.projectanduseProjection.EventsOf<typeof modules>plusLoomsRegistergives React hooks the application event union.
1. Catalog#
Catalog keys become namespaced event types. Payload schemas fix what builders and reducers accept:
import { z } from 'zod'import { defineEventCatalog } from '@swirls/looms/core'const ChargeRequested = z.object({ chargeId: z.string(), amount: z.number(), currency: z.string(),})export const paymentsCatalog = defineEventCatalog('payments', { 'charge.requested': ChargeRequested, 'charge.authorized': ChargeRequested, 'charge.declined': ChargeRequested.extend({ reason: z.string() }),})// Event type: 'payments.charge.authorized'paymentsCatalog.input('charge.authorized', { chargeId: 'ch_123', amount: 40, currency: 'USD',})2. Scoped reducers and effects#
Inside defineModule, event.type is a discriminated union over the module catalog (plus protocol events and any observes catalogs). Effect handlers may only return events from that universe:
export const payments = defineModule( { namespace: 'payments', protocolVersion: '1.0.0', events: paymentsCatalog, }, (m) => ({ effects: { charge: m.effect({ type: 'payments.charge', input: z.object({ amount: z.number(), currency: z.string().optional() }), execute: (input, ctx) => [ { type: 'payments.charge.requested', payload: { chargeId: ctx.effectId, amount: input.amount, currency: input.currency ?? 'USD', }, }, ], }), }, projections: { ledger: m.projection({ name: 'ledger', shape: z.object({ entries: z.array( z.object({ chargeId: z.string(), amount: z.number(), currency: z.string(), status: z.enum(['requested', 'authorized', 'declined']), }), ), }), initialState: { entries: [] }, reduce(state, event) { switch (event.type) { case 'payments.charge.authorized': return { entries: [ ...state.entries, { chargeId: event.payload.chargeId, amount: event.payload.amount, currency: event.payload.currency, status: 'authorized' as const, }, ], } default: return state } }, }), }, }),)export const charge = payments.effects.chargeexport const ledger = payments.projections.ledger3. Typed invoke#
Pass the effect definition when you want input checking:
import { invoke } from '@swirls/looms/core'invoke(charge, { amount: 40, currency: 'USD' })4. Projection state#
The projection's shape (or initialState) is the state type. Server and client share the same definition:
const book = await looms.project(runId, ledger)// book.entries: { chargeId, amount, currency, status }[]5. React register#
Augment LoomsRegister with your composed modules so hooks see the same event union as the host:
import type { EventsOf } from '@swirls/looms/core'import type {} from '@swirls/looms/react'import { approval } from '@swirls/looms/approval'import { workflow } from '@swirls/looms/workflow'const modules = [workflow({ definitions: [checkout] }), approval(), payments] as constexport type AppEvents = EventsOf<typeof modules>declare module '@swirls/looms/react' { interface LoomsRegister { events: AppEvents }}import { useProjection, useRunStore } from '@swirls/looms/react'function Ledger({ runId }: { runId: string }) { const store = useRunStore(runId) const book = useProjection(store, ledger) return <p>{book.entries.length} charges</p>}TypeScript catches this#
// @ts-expect-error — unknown catalog keypaymentsCatalog.input('charge.authorised', { chargeId: 'x', amount: 1, currency: 'USD' })// @ts-expect-error — wrong payload shapepaymentsCatalog.input('charge.authorized', { chargeId: 'x', amount: '40', currency: 'USD' })// @ts-expect-error — wrong effect inputinvoke(charge, { amount: '40' })Boundaries#
- Zod / Effect / Standard Schema catalog entries validate at runtime on signal and effect boundaries.
payload<T>()is compile-time only. - Wire events (SSE / HTTP) decode as generic envelopes.
LoomsRegistersupplies the application event union to React; trust still depends on the host. - Scoped
m.thread/m.projection/m.effectgive stronger event narrowing than standalonedefineThread/defineProjection/defineEffect. wait({ on: { type } })matches on strings; it is not catalog-parameterized.
Authoring lives in Modules. Projection folds live in Projections. Catalogs and signals live in Events & effects.