Stagehand
Splitting Steps Across Files
stepFor binds the input and context a step expects while keeping full type inference, for steps declared away from any one script.
A step defined inline with addStep infers its In and Ctx from the script
it is attached to. A step declared in its own module has no script to infer
from — stepFor supplies that binding explicitly, while keeping the same
inference on the handler’s return type:
// steps/verify.ts
import { stepFor } from "@michaelrwalker/stagehand";
export const verifyId = stepFor<Input, { user: User }>()({
name: "verify id",
handler: async ({ ctx }) => ({ verified: ctx.user.id }),
rollbackKeys: ["verified"],
rollback: async ({ ctx }) => unverify(ctx.verified),
});
The two type parameters on stepFor<In, Ctx>() are the minimum the step
needs — the input it reads (In) and the context it expects to already exist
(Ctx). Both default to the smallest useful shape (In has no default and must
be supplied if the handler reads input; Ctx defaults to {}) so a step that
only needs input, not context, can omit the second parameter entirely:
export const loadAccount = stepFor<{ accountId: string }>()({
name: "load account",
handler: async ({ input }) => ({ account: await fetchAccount(input.accountId) }),
});
rollbackKeys works exactly as it does inline (see
Rollbacks), and the keys it names stay reserved once the
step is handed to addStep, in whichever script that turns out to be.
Declaring Ctx from an earlier step
Writing the second stepFor in a chain means restating what the first one
returns, by hand, as its Ctx. WithStepFor<Step, Rest> computes it instead:
the step’s output merged over whatever else the context already holds:
import { stepFor, type WithStepFor } from "@michaelrwalker/stagehand";
export const logIntoDb = stepFor<Input>()({
name: "log into db",
handler: async () => ({ conn: await connect() }),
});
export const loadRules = stepFor<
Input,
WithStepFor<typeof logIntoDb, { conditions: Array<{ name: string }> }>
>()({
name: "load rules",
// ctx.conn comes from the step, ctx.conditions from the rest
handler: ({ ctx }) => ({ rules: ctx.conn.query(ctx.conditions) }),
});
Rest defaults to {}, so WithStepFor<typeof logIntoDb> is just that step’s
output. It nests, which is how a longer chain composes:
WithStepFor<typeof second, WithStepFor<typeof first, { conditions: string[] }>>
Read that inside out: start with conditions, add what first returns, then
what second returns. That is also the precedence: on a name collision the
outermost wins, the same way a later step’s return value shadows an earlier key
at runtime. Nest in the order the steps actually run and the two agree.
Two things it deliberately does not do. It reads only the step’s output, not
the step’s own Ctx requirement, since the two are declared independently;
addStep remains the place where a step meeting an insufficient context is
caught. And it is a convenience for declaring a type, not a second source of
truth: nothing checks that the steps are added to a script in the order the
nesting implies.
A step that returns nothing leaves Rest untouched, and an async handler is
awaited first, so neither needs special handling.
Using it
import { Script } from "@michaelrwalker/stagehand";
import { loadAccount, createTenant, seedDefaults } from "./steps/tenancy.ts";
const signup = new Script<{ accountId: string; sendEmail: boolean }>({ name: "signup" })
.addPhase("Account")
.addStep(loadAccount)
.addPhase("Provision")
.addStep(createTenant)
.addStep(seedDefaults)
.addPhase("Notify")
.addStep({
name: "send welcome email",
// Inline steps and imported ones mix freely — ctx here already knows about
// everything the imported steps returned.
when: ({ input }) => input.sendEmail,
handler: async ({ ctx, success }) => {
success(`welcomed ${ctx.account.owner} on ${ctx.tenantId}`);
return { emailed: true };
},
});
addStep is where the requirement is actually enforced: TypeScript checks that
the script has produced Ctx by the point the step is added. Dropping
createTenant (which needs { account: Account }) into a script that has not
loaded an account yet is a compile error at the addStep call, not a runtime
undefined inside the handler.
Argument of type '{ name: string; handler: ... }' is not assignable to
parameter of type ... Property 'account' is missing in type '{}'.
Reusing one step in two scripts
The whole point of pulling a step into its own module is using it more than
once. The same createTenant step can compensate identically in two unrelated
scripts, as long as each satisfies its Ctx requirement by the time it is
added:
// Script one: a normal signup
new Script<{ accountId: string; sendEmail: boolean }>({ name: "signup" })
.addPhase("Account")
.addStep(loadAccount)
.addPhase("Provision")
.addStep(createTenant) // its rollback compensates here
.addStep(seedDefaults);
// Script two: a region migration, written later, reusing the same step
new Script<{ accountId: string; fromRegion: string }>({ name: "migrate" })
.addPhase("Prepare")
.addStep(loadAccount)
.addStep({
name: "snapshot old region",
handler: async ({ input }) => ({ snapshotId: await snapshot(input.fromRegion) }),
rollbackKeys: ["snapshotId"],
rollback: async ({ ctx }) => thaw(ctx.snapshotId),
})
.addPhase("Provision")
.addStep(createTenant) // same import, same rollback, compensates here too
.addStep({
name: "restore snapshot",
handler: async ({ ctx }) => ({ restored: await restore(ctx.tenantId) }),
});
If “restore snapshot” fails in the second script, both createTenant’s and
loadAccount’s rollbacks (if loadAccount had one) run — the imported step’s
compensation logic is identical wherever it is mounted, because it is the exact
same function reference each time.
When to reach for a whole routine instead
stepFor binds one step. When several steps travel together as a unit, a
whole “fetch and normalize” pipeline that several scripts want, phases and all,
routineFor does the same job one level up. See
Reusable Scripts and Mounts.