@gramio/composer
@gramio/composer is the general-purpose, type-safe middleware composition library that powers GramIO's internals. If you're writing a plugin, building a framework on top of GramIO, or just want to understand how context enrichment works — this is the place to start.
Installation
npm install @gramio/composeryarn add @gramio/composerpnpm add @gramio/composerbun add @gramio/composerCore Concepts
A Composer is a chainable middleware pipeline. Each method registers a new middleware step and returns the (updated) composer for chaining:
import { Composer } from "@gramio/composer";
const app = new Composer<{ request: Request }>()
.use(logger)
.derive(fetchUser)
.guard(isAuthenticated)
.use(handler);use()
Register raw middleware. The handler receives (context, next) and must call next() to continue the chain:
app.use(async (ctx, next) => {
console.log("before");
await next();
console.log("after");
});derive()
Enriches context with computed values. The returned object is merged into the context for all downstream middleware:
app.derive(async (ctx) => {
const user = await db.findUser(ctx.userId);
return { user };
});
// ctx.user is now available downstreamdecorate()
Like derive(), but for static values that don't need per-request computation. Assigns the object once at registration time and reuses the same reference — zero function call overhead:
app.decorate({ db: myDatabase, config: appConfig });
// ctx.db and ctx.config available on every request, no overheadSupports scoping with { as: "scoped" } or { as: "global" } to propagate through extend().
guard()
Only continues the chain if the predicate returns true:
app.guard((ctx) => ctx.user.isAdmin);
// Subsequent middleware only runs for adminswhen()
Build-time conditional middleware registration. The condition is evaluated once at startup, not per-request. Properties added inside the block are typed as Partial (optional):
const app = new Composer()
.when(process.env.NODE_ENV !== "production", (c) =>
c.use(verboseLogger)
)
.when(config.features.analytics, (c) =>
c.derive(() => ({ analytics: createAnalyticsClient() }))
);Differences from branch():
when()— condition evaluated once at startup (build-time)branch()— condition evaluated on every request (runtime)
Nested when() blocks work. Dedup keys, error handlers, and error definitions propagate from the conditional block.
Observability
inspect()
Returns a read-only snapshot of all registered middleware with metadata:
const app = new Composer()
.derive(function getUser() { return { user: "alice" }; })
.guard(function isAdmin() { return true; })
.use(async function handleRequest(_, next) { return next(); });
app.inspect();
// [
// { index: 0, type: "derive", name: "getUser", scope: "local" },
// { index: 1, type: "guard", name: "isAdmin", scope: "local" },
// { index: 2, type: "use", name: "handleRequest", scope: "local" },
// ]When a named plugin is extended, the plugin field shows the source:
const auth = new Composer({ name: "auth" })
.derive(function getUser() { return { user: "alice" }; })
.as("scoped");
new Composer().extend(auth).inspect();
// [{ index: 0, type: "derive", name: "getUser", scope: "local", plugin: "auth" }]trace()
Opt-in per-middleware instrumentation hook. Zero overhead when not used — middleware functions are passed through unwrapped when no tracer is set:
app.trace((entry, ctx) => {
const span = tracer.startSpan(`${entry.type}:${entry.name ?? "anonymous"}`);
span.setAttributes({
"middleware.index": entry.index,
"middleware.scope": entry.scope,
...(entry.plugin && { "middleware.plugin": entry.plugin }),
});
return (error) => {
if (error) span.recordException(error as Error);
span.end();
};
});The TraceHandler callback:
- Is called before each middleware executes with
MiddlewareInfoand context - May return a cleanup function
(error?: unknown) => void - Cleanup is called after middleware completes (with no args on success, with the error on failure)
- Errors still propagate to
onErrorafter cleanup
Scope System
The scope system controls how middleware propagates when one composer extends another:
| Scope | Behavior |
|---|---|
"local" (default) | Isolated inside an isolation wrapper — context does not leak to parent |
"scoped" | Adds directly to parent as a local entry — visible to parent's downstream middleware |
"global" | Adds to parent as global — continues propagating through further extend() calls |
Promote a whole composer to a scope with .as():
const plugin = new Composer({ name: "auth" })
.derive(function getUser() { return { user: "alice" }; })
.as("scoped"); // everything in this composer is scoped
app.extend(plugin); // getUser is now visible in app's downstreamError Handling
class NotFoundError extends Error {}
const app = new Composer()
.error("NotFound", NotFoundError)
.onError(({ error, kind, context }) => {
if (kind === "NotFound") {
context.send("Resource not found");
return "handled";
}
})
.use(() => { throw new NotFoundError("Item missing"); });Multiple onError() handlers are evaluated in order — the first to return a non-undefined value wins. Errors without a matching handler are logged via console.error.
Plugin Development
For plugin authors, @gramio/composer is the foundation of GramIO's Plugin class. The concepts map directly:
import { Plugin } from "gramio";
// Plugin uses the same Composer API internally
const myPlugin = new Plugin("my-plugin")
.decorate({ db: myDatabase }) // static enrichment
.derive(async () => ({ user: ... })) // per-request enrichment
.on("message", handler); // event handlerFor advanced plugin creation that needs custom shorthand methods or observability, work directly with @gramio/composer.
createComposer() — Building Custom Frameworks
If you're building a framework on top of @gramio/composer and need custom shorthand methods (like GramIO's own hears(), command(), reaction()), use createComposer():
import { createComposer, eventTypes } from "@gramio/composer";
const { Composer } = createComposer({
discriminator: (ctx: BaseCtx) => ctx.updateType,
types: eventTypes<{ message: MessageCtx; callback_query: CallbackCtx }>(),
methods: {
hears(trigger: RegExp | string, handler: (ctx: MessageCtx) => unknown) {
return this.on("message", (ctx, next) => {
const text = ctx.text;
if (typeof trigger === "string" ? text === trigger : trigger.test(text ?? ""))
return handler(ctx);
return next();
});
},
command(cmd: string, handler: (ctx: MessageCtx) => unknown) {
return this.on("message", (ctx, next) => {
if (ctx.text?.startsWith(`/${cmd}`)) return handler(ctx);
return next();
});
},
},
});
// Custom methods survive through all chain operations:
const app = new Composer()
.hears(/hello/, h1) // custom method
.on("message", h2) // built-in — TMethods still preserved
.hears(/bye/, h3); // custom method still availabletypes + eventTypes(): TypeScript cannot partially infer type arguments. The types phantom field with eventTypes() helper lets you specify TEventMap without losing TMethods inference:
// Instead of explicit type parameters (can't infer TMethods):
createComposer<BaseCtx, { message: MessageCtx }>({ ... })
// Use the phantom types pattern:
createComposer({
discriminator: (ctx: BaseCtx) => ctx.updateType,
types: eventTypes<{ message: MessageCtx }>(), // inferred, not explicit
methods: { /* TMethods inferred from here */ },
})A runtime conflict check throws if a methods key collides with a built-in method name.
defineComposerMethods() — generic custom methods with derives
When custom methods have generic signatures that need to capture accumulated derives, use defineComposerMethods() first. TypeScript cannot infer generic method signatures when TMethods is nested inside the return type of createComposer, so the helper is required:
import { defineComposerMethods, createComposer } from "@gramio/composer";
import type { ComposerLike, ContextOf, Middleware } from "@gramio/composer";
const methods = defineComposerMethods({
command<TThis extends ComposerLike<TThis>>(
this: TThis,
name: string,
handler: Middleware<MessageCtx & ContextOf<TThis>>,
): TThis {
return this.on("message", (ctx, next) => {
if (ctx.text === `/${name}`) return handler(ctx, next);
return next();
});
},
});
const { Composer } = createComposer<BaseCtx, EventMap, typeof methods>({
discriminator: (ctx) => ctx.updateType,
methods,
});
// Derives flow into the handler automatically — zero annotation:
new Composer()
.derive(() => ({ user: { id: 1, name: "Alice" } }))
.command("start", (ctx) => {
ctx.user.id; // ✅ typed — inferred via ContextOf<TThis>
ctx.text; // ✅ from MessageCtx
});ContextOf<T> — extract the current context type
Extracts TOut (the fully accumulated context after all derive()/decorate() calls) from a Composer or EventComposer instance type. Most useful in defineComposerMethods() custom method signatures so that derives flow in automatically:
import type { ContextOf } from "@gramio/composer";
type Ctx = ContextOf<typeof myComposer>;
// Ctx = accumulated context including all derive() resultsEventContextOf<T, E> — per-event context type
Extracts the context for a specific event from a composer instance, including both global and per-event derives:
import type { EventContextOf } from "@gramio/composer";
// Per-event derive: only visible in 'message' handlers
composer.derive(['message'], () => ({ messageData: "..." }));
type MessageCtx = EventContextOf<typeof composer, 'message'>;
// Includes both global derives AND messageDataComposerLike<T> — minimal structural type for this constraints
A minimal interface { on(event: any, handler: any): T } used as an F-bounded constraint on TThis in custom methods. Makes this.on(...) fully typed and return TThis without casts.
Macro System
Register reusable behaviors that handlers activate declaratively via an options object. Useful for cross-cutting concerns like authentication, rate limiting, validation — without polluting handler bodies with boilerplate checks:
// Register a macro
const app = new Composer().macro("adminOnly", {
preHandler: async (ctx, next) => {
if (ctx.userId !== ADMIN_ID) return ctx.reply("Admins only");
return next();
},
});
// Activate per handler via options:
app.on("message", handler, { adminOnly: true });
app.on("callback_query", handler, { adminOnly: true });macro() accepts either:
- Plain
MacroHooksobject — for boolean shorthand ({ adminOnly: true }) (opts) => MacroHooksfunction — for parameterized options ({ throttle: { limit: 3 } })
MacroHooks has:
preHandler— middleware that runs before the handlerderive— context enrichment function; returningvoidstops the chain