Your first context
Five minutes. One file. By the end you will have a typed UserContext that flows through async calls without ever appearing in a function signature below the boundary.
The problem you are solving
Section titled “The problem you are solving”// You start here — userId in hand.async function handleRequest(userId: string) { await loadUserPreferences(); // ← needs userId}
// Five frames deep, you still need it.async function fetchAuditTrail() { // userId? It's gone. return [];}Threading userId through every signature works. It also pollutes every function that should not care about authentication just to relay a value. Context lets you read it where you need it.
Step 1 — Declare the store shape
Section titled “Step 1 — Declare the store shape”A plain TypeScript type that describes what the context holds:
type UserContextStore = { userId: string; role: "admin" | "user";};Use type for the store shape — it is data, not a contract.
Step 2 — Extend Context<TStore>
Section titled “Step 2 — Extend Context<TStore>”import { Context } from "@warlock.js/context";
type UserContextStore = { userId: string; role: "admin" | "user";};
class UserContext extends Context<UserContextStore> { public buildStore(payload?: Record<string, any>): UserContextStore { return { userId: payload?.userId ?? "", role: payload?.role ?? "user", }; }}
export const userContext = new UserContext();buildStore is the only method you must implement — everything else (run, get, set, update, …) is inherited. It is called by contextManager.buildStores(payload) to seed the store at the boundary; the payload is whatever you pass in (a req object, a job message, a CLI args bag).
Step 3 — run() at the boundary
Section titled “Step 3 — run() at the boundary”The boundary is wherever your scope starts — an HTTP handler, a queue consumer, a scheduled job:
import { userContext } from "./contexts/user-context";
async function handleRequest(req: { userId: string; role: "admin" | "user" }) { await userContext.run({ userId: req.userId, role: req.role }, async () => { await loadUserPreferences(); });}Everything inside the callback — and every async function it awaits, no matter how deep — sees the same store.
Step 4 — Read it five frames deep
Section titled “Step 4 — Read it five frames deep”import { userContext } from "../contexts/user-context";
export async function fetchAuditTrail() { const userId = userContext.get("userId"); const role = userContext.get("role");
if (!userId) { throw new Error("fetchAuditTrail called outside a user context"); }
return queryAuditLogFor(userId, role);}get("userId") returns string | undefined, typed off the store shape. The function did not take userId as a parameter — it pulled it from the active scope.
Step 5 — Run it end-to-end
Section titled “Step 5 — Run it end-to-end”import { userContext } from "./contexts/user-context";import { fetchAuditTrail } from "./services/audit.service";
async function deepWork() { return fetchAuditTrail();}
async function loadUserPreferences() { return deepWork();}
await userContext.run( { userId: "u-123", role: "admin" }, async () => { const trail = await loadUserPreferences(); console.log(trail); },);fetchAuditTrail never received userId, but it read "u-123" from the active context. When run() returns, the store is gone — the next concurrent call gets its own.
What just happened
Section titled “What just happened”userContext.run(store, fn)calls Node’sAsyncLocalStorage.run(store, fn)under the hood.- Node tracks the store through every
awaitand microtask boundary insidefn. - Two concurrent calls to
run()get isolated stores — no leaks between them. - When
fnreturns or throws, the store is released. No cleanup code needed.
Common follow-ups
Section titled “Common follow-ups”- Need more than one context? A request typically wants user + trace + tenant active at once. The next-level pattern is
contextManager.runAll()— see Orchestrate contexts. - Middleware without a callback? Express-style middlewares call
next()and return synchronously. UseuserContext.enter(store)instead ofrun()— see Define a context. - Want a shorter accessor? Add a getter on the subclass:
get userId() { return this.get("userId"); }. ThenuserContext.userIdreads cleaner thanuserContext.get("userId").
Related
Section titled “Related”- The context model — how AsyncLocalStorage propagates through awaits.
- Define a context — patterns for typed contexts.
- API reference — every exported member.