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 are compiled by the docs' own health check against real package source, so the shapes shown are what TypeScript actually infers — not a paraphrase.

filter queries narrow context

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

filters.ts
import { createBot } from "yaebal";

const bot = createBot(process.env.BOT_TOKEN!);

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
import { createBot } from "yaebal";

const loadUser = async (id: number) => ({ id, name: "somebody" });
const token = process.env.BOT_TOKEN!;

const bot = createBot(token)
  .decorate({ appName: "shop" })
  .derive(async (ctx) => ({ user: await loadUser(ctx.from!.id) }))
  .on("message:text", (ctx) => {
    ctx.appName;
    // ^ string
    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. see the session plugin for storage options.

session.ts
import { createBot, session } from "yaebal";

interface SessionData {
  cart: string[];
}

const token = process.env.BOT_TOKEN!;

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. see plugin authoring for the full Plugin contract.

dependency.ts
import { createBot, session, type Context, type Plugin } from "yaebal";

type NeedsSession = Context & { session: { userId?: number } };
type UserRecord = { id: number; name: string };

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

const loadUser = async (id: number) => ({ id, name: "somebody" });
const token = process.env.BOT_TOKEN!;

// @ts-expect-error — session isn't installed on this chain yet, so `ctx.session`
// doesn't exist. this is a real compile error: our docs health check compiles
// this exact snippet, so if plugin-dependency checking ever regresses, this
// page's build breaks too — not just a paraphrased comment.
createBot(token).install(currentUser(loadUser));

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

guard narrows with a type predicate

guard has two forms: a plain boolean predicate just gates the chain, but a type-guard predicate (ctx is C2) also narrows the context type for every handler registered after it — the same mechanism filter uses for staged data.

guard.ts
import { createBot, type Context } from "yaebal";

type WithUser = Context & { from: NonNullable<Context["from"]> };

const bot = createBot(process.env.BOT_TOKEN!)
  .guard((ctx): ctx is WithUser => ctx.from !== undefined)
  .on("message", (ctx) => {
    ctx.from.id;
    // ^ number — no `!` needed. guard() with a type-predicate narrows every
    //   handler registered after it, the same way derive()/filter() do.
  });

filter stages typed data

a filter from @yaebal/filters can stage extra fields (a regex match, a resolved chat member, …) without touching the context until the whole filter tree matches — a rejected branch leaves ctx exactly as it was.

filter.ts
import { createBot } from "yaebal";
import { regex } from "@yaebal/filters";

const bot = createBot(process.env.BOT_TOKEN!)
  .filter(regex(/^\/order (\d+)$/), (ctx) => {
    ctx.match[1];
    // ^ string | undefined — `regex()` stages { text, match } in the filter's
    //   bag; filter() commits it onto the context only once the whole filter
    //   tree matches, so a rejected filter never touches ctx at all.
    return ctx.reply("order: " + (ctx.match[1] ?? "?"));
  });

generated contexts are runtime shortcuts

use createBot() from the meta package when you want generated per-update shortcuts at runtime. the full shortcut list and positional overloads live on generated contexts.

generated-contexts.ts
import { createBot } from "yaebal";

const bot = createBot(process.env.BOT_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. note that extend copies middleware — attach a feature that already extends shared without extending shared again, or its plugins run twice per update.

feature-composer.ts
import { Composer, createBot, session } from "yaebal";

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(process.env.BOT_TOKEN!).extend(feature);
// feature already carries shared's middleware — extend it once.
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.