Pinning types with a bag
By default a graph journey infers its types from the definition: step ids from the steps record’s
keys, event names from each step’s on keys. A call site declares no generics.
Some types cannot be inferred, because they sit at a property position rather than an inference
site — injected handlers, a meta shape, the result of a declared run. And steps authored in
separate files need to name the shape they satisfy. Both are what the bag is for.
The bag
Section titled “The bag”type AuthBag = { context: LoginContext; stepId: "login" | "twofa" | "done"; events: { type: "submit"; payload: { user: string } } | { type: "verify" }; meta?: { title: string }; handlers?: { isAdmin(role: string): boolean }; results?: { verify: { ok: boolean } };};context, stepId and events are required. meta, handlers and results are optional and
fall back — metadata to Record<string, unknown>, handlers to an empty record, results to
unknown.
results is load-bearing rather than a convenience. A declared run sits at a property position,
so commit’s result is unknown unless the bag pins it. One result type per event name, not per
(step, event) pair.
If restating the result type is the part you object to, defineWork
reads it off run instead.
withGraphTypes — pinning at the call
Section titled “withGraphTypes — pinning at the call”const login = withGraphTypes<AuthBag>()({ initial: "login", context: initialContext, steps: { login: { on: { submit: "twofa" } }, twofa: { on: { verify: { run: ({ handlers }) => handlers.verify(), commit: ({ result, updateContext }) => updateContext((context) => ({ ...context, ok: result.ok })), candidates: [{ to: "done", when: ({ context }) => context.ok }, { to: "twofa" }] } } }, done: {} }});The definition is now checked against the bag rather than read for types. withLinearTypes is the
linear twin, and @rxova/journey-react/graph exports its own withGraphTypes returning a bundle.
These are standalone functions rather than a .withTypes property on the factory. Attaching one
would be a module-level side effect, and that defeats tree-shaking badly enough that importing only
createLinearJourney pulled the entire graph tier into the bundle.
defineWork — inferring the run result
Section titled “defineWork — inferring the run result”The bag’s results exists because a declared run sits at a property position, and TypeScript does
not infer through one. A generic function call is an inference site, so passing the same config
through one reads the result type off run and nothing needs restating:
import { defineWork, withGraphTypes } from "@rxova/journey-core";
const login = withGraphTypes<AuthBag>()({ initial: "login", context: initialContext, steps: { login: { on: { submit: "twofa" } }, twofa: { on: { verify: defineWork<AuthBag, "verify">()({ run: ({ handlers }) => handlers.verify(), // the result type comes from here commit: ({ result, updateContext }) => updateContext((context) => ({ ...context, ok: result.ok })), candidates: [ { to: "done", label: "verified", when: ({ context }) => context.ok }, { to: "twofa", label: "retry" } ] }) } }, done: {} }});Two things follow from the call being generic, neither of which the property form can offer:
commit gets a typed result, and run’s event is narrowed to the key the entry is declared
under.
Guards are unaffected and stay total functions of context: the result reaches them only through the
context commit stages. Routing a guard directly on the run result is deliberately not available —
the same guards run during snapshot derivation, where no send is in flight, so availableEvents
would disagree with what a send actually does.
The call is curried because TypeScript infers all of a call’s type arguments or none: naming the bag and the event inline would opt the result type out of inference too. The empty second call is the price of pinning the first two and inferring the third.
defineWork pairs with a bag, so reach for it through withGraphTypes<TBag>() — a definition that
infers stepId, events and handlers on its own will not line up with the bag’s.
GraphStep — steps in their own files
Section titled “GraphStep — steps in their own files”import type { GraphStep } from "@rxova/journey-core";import type { AuthBag } from "../types";
export const loginStep: GraphStep<AuthBag> = { metadata: { title: "Sign in" }, onLeave: ({ snapshot }) => analytics.track("login_left", snapshot.context), on: { submit: [ { to: "admin", when: ({ context, handlers }) => handlers.isAdmin(context.role) }, { to: "dashboard" } ] }};Compose them into the steps record keyed by id:
const definition = { initial: "login", context: initialContext, steps: { login: loginStep, dashboard: dashboardStep, admin: adminStep }} satisfies GraphDefinition<AuthBag>;
export const journey = withGraphTypes<AuthBag>()(definition);GraphDefinition — one definition, several machines
Section titled “GraphDefinition — one definition, several machines”withGraphTypes pins a definition it is handed directly. When the same definition is created once and
passed to the factory more than once — different options per call, a fake client in tests —
annotate it instead:
export const definition = { initial: "login", context: initialContext, handlers: realApi, steps: { login: loginStep, done: {} }} satisfies GraphDefinition<AuthBag>;
const live = withGraphTypes<AuthBag>()(definition);const underTest = withGraphTypes<AuthBag>()(definition, { handlers: fakeApi });Annotate the context separately when the definition is a standalone const. satisfies preserves
literal types, so an inline { dirty: false } pins the field to false and rejects every later
update:
const initialContext: LoginContext = { user: "", dirty: false };