Skip to content
Looms
Documentation
On this page

Type safety

Types start in the event catalog and flow through module reducers, effects, projections, and React hooks via EventsOf and LoomsRegister.

The chain#

  1. A Zod-backed (or Effect / Standard Schema) event catalog fixes event names and payloads.
  2. defineModule scope narrows event in thread.step / projection.reduce and checks effect outputs.
  3. Typed invoke(effectDef, input) checks effect input.
  4. ProjectionDefinition<S> carries projection state into looms.project and useProjection.
  5. EventsOf<typeof modules> plus LoomsRegister gives React hooks the application event union.

1. Catalog#

Catalog keys become namespaced event types. Payload schemas fix what builders and reducers accept:

ts
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:

ts
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.ledger

3. Typed invoke#

Pass the effect definition when you want input checking:

ts
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:

ts
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:

ts
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  }}
tsx
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
// @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. LoomsRegister supplies the application event union to React; trust still depends on the host.
  • Scoped m.thread / m.projection / m.effect give stronger event narrowing than standalone defineThread / 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.