Why GramIO
GramIO is a TypeScript-first Telegram Bot API framework that runs on Node.js, Bun, and Deno. It's built around one idea: the type system should work for you, not against you.
Here's what that means in practice.
Types that flow, not types you fight
Most frameworks give you a typed context at entry. GramIO goes further — types propagate through the entire chain as you build it.
import { Bot } from "gramio";
import { db } from "./db";
const bot = new Bot(process.env.BOT_TOKEN as string)
// add to every handler — typed automatically
.derive(["message"], async (ctx) => ({
user: await db.getUser(ctx.from!.id),
}))
// ctx.user is now fully typed here
.on("message", (ctx) => {
ctx.user.role;
//
//
return ctx.send(`Hi, ${ctx.user.name}!`);
})
// ...and here, even for a different event
// excluded from derive events
.on("callback_query", (ctx) => ctx.user.balance);No casting. No manual type annotations. No (ctx as any).user.
Formatting without parse_mode
Telegram formatting with raw HTML or MarkdownV2 is painful — you have to escape everything manually and track parse_mode. GramIO uses tagged template literals that produce proper MessageEntity objects:
bot.command("start", (ctx) =>
ctx.send(
format`${bold`Welcome!`} — ${italic("no parse_mode needed")}.
Version: ${code("2.0.0")}
${spoiler`secret`} · ${link("gramio.dev", "https://gramio.dev")}`,
),
);No escaping. No parse_mode: "HTML". No broken messages when a username contains < or >.
Plugins that compose, not configure
Plugins in GramIO use .extend() — the same mechanism as everything else. A plugin can add context properties, register handlers, and hook into the lifecycle. And it's all typed end-to-end:
import { Bot } from "gramio";
import { scenes, Scene } from "@gramio/scenes";
import { session } from "@gramio/session";
const onboarding = new Scene("onboarding")
.step("message", (ctx) => {
if (ctx.scene.step.firstTime) return ctx.send("What's your name?");
return ctx.scene.update({ name: ctx.text });
})
.step("message", (ctx) => ctx.send(`Welcome, ${ctx.scene.state.name}!`));
const bot = new Bot(process.env.BOT_TOKEN as string)
.extend(session())
.extend(scenes([onboarding]))
.command("start", (ctx) => ctx.scene.enter(onboarding));
// fully typed — ctx.scene comes from the plugin
bot.start();Comparison
| Feature | GramIO | grammY | Telegraf |
|---|---|---|---|
| Language | TypeScript | TypeScript | TypeScript |
| Type propagation through middleware | ✅ Full | ✅ Full | ⚠️ Partial |
derive() with auto-typed context | ✅ | ✅ | ❌ |
Formatting without parse_mode | ✅ Built-in | ❌ Manual | ❌ Manual |
| Plugin system | ✅ .extend() | ✅ Flavors | ⚠️ Middleware |
| Multi-runtime (Node/Bun/Deno) | ✅ | ✅ | ⚠️ Node focused |
| Code-generated API types | ✅ Auto-published | ✅ | ⚠️ |
| Built-in test utilities | ✅ @gramio/test | ❌ | ❌ |
| Full Telegram API reference | ✅ /telegram/ | ❌ | ❌ |
| Scenes / conversations | ✅ @gramio/scenes | ✅ Conversations | ✅ Scenes |
| I18n | ✅ @gramio/i18n (TS-native) | ✅ | ⚠️ |
| Scaffolding CLI | ✅ create gramio | ❌ | ❌ |
Everything is a Composer
Bot extends Composer — a chainable, type-safe middleware pipeline. Every .derive(), .guard(), .on() returns the updated type. You can extract a Composer for any feature, test it in isolation, then .extend() it into your bot:
// src/plugins/index.ts — register once
export const composer = new Composer()
.extend(session())
.extend(scenes([onboarding]));
// src/features/profile.ts — fully typed, no Bot needed
export const profileFeature = new Composer()
.extend(composer) // inherits ctx.session, ctx.scene
.command("profile", (ctx) => ctx.send(ctx.session.name ?? "anon"));
// src/index.ts — wire together
const bot = new Bot(token).extend(composer).extend(profileFeature);