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.
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.
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.
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.
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.
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.
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.
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.
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.declare module just to make ctx.foo exist. add it with derive, decorate, or a typed plugin
so both runtime and typescript stay aligned.