typed examples

yaebal's main feature is not just runtime convenience. it is the way the context type changes as you build the chain. these examples show the intended typescript shape.

filter queries narrow context

a query like message:text is both a runtime route and a type-level narrowing rule.

filters.ts
bot.on("message:text", (ctx) => {
  ctx.text;
  // ^ string, narrowed by the filter query
});

bot.on("callback_query:data", (ctx) => {
  ctx.callbackQuery.data;
  // ^ string | undefined on the raw payload, but callbackQuery itself is guaranteed
});

derive and decorate accumulate

decorate adds static fields and derive adds per-update fields. handlers downstream see both.

accumulate.ts
const bot = createBot(token)
  .decorate({ appName: "shop" })
  .derive(async (ctx) => ({ user: await loadUser(ctx.from?.id) }))
  .on("message:text", (ctx) => {
    ctx.appName;
    // ^ "shop"
    ctx.user;
    // ^ Awaited<ReturnType<typeof loadUser>>
    ctx.text;
    // ^ string
  });

plugin-added context stays typed

the session shape comes from the generic you pass to session() and flows into later handlers.

session.ts
interface SessionData {
  cart: string[];
}

const bot = createBot(token)
  .install(session<SessionData>({ initial: () => ({ cart: [] }) }))
  .command("cart", (ctx) => {
    ctx.session.cart.push("sku_1");
    // ^ string[]
  });

plugin dependencies are explicit

a plugin can say which context fields it requires. installing it too early becomes a compile-time error instead of a hidden middleware-order bug.

dependency.ts
type NeedsSession = Context & { session: { userId?: number } };

function currentUser(loadUser: (id: number) => Promise<User>): Plugin<NeedsSession, { user: User | null }> {
  return (composer) => composer.derive(async (ctx) => ({
    user: ctx.session.userId ? await loadUser(ctx.session.userId) : null,
  }));
}

new Bot(token).install(currentUser(loadUser));
// TypeScript error: session is not installed yet.

new Bot(token)
  .install(session({ initial: () => ({}) }))
  .install(currentUser(loadUser));
	// ok.

generated contexts are runtime shortcuts

use createBot() from the meta package when you want generated per-update shortcuts at runtime.

generated-contexts.ts
const bot = createBot(token);

bot.on("message:text", (ctx) => {
  ctx.react("🔥");
  // ^ generated MessageContext shortcut
});

bot.on("callback_query:data", (ctx) => {
  ctx.answer("ok");
  // ^ generated CallbackQueryContext shortcut
});

feature composers inherit types

build features as plain composers, extend shared plugin setup, then attach them to the bot. the context type follows the chain.

feature-composer.ts
const shared = new Composer()
  .install(session({ initial: () => ({ count: 0 }) }))
  .decorate({ feature: "shared" });

const feature = new Composer()
  .extend(shared)
  .command("count", (ctx) => {
    ctx.session.count;
    ctx.feature;
  });

createBot(token).extend(shared).extend(feature);
no declaration merging required. do not add declare module just to make ctx.foo exist. add it with derive, decorate, or a typed plugin so both runtime and typescript stay aligned.