Skip to content

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.

ts
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);
Property 'user' does not exist on type 'CallbackQueryContext<Bot<{}, DeriveDefinitions & { message: { user: { name: string; role: "admin"; balance: number; }; }; }>>'.

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:

ts
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:

ts
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
) =>
c
tx
.
scene
.
enter
(
onboarding
))
;
// fully typed — ctx.scene comes from the plugin
bot
.
start
();

Comparison

FeatureGramIOgrammYTelegraf
LanguageTypeScriptTypeScriptTypeScript
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 CLIcreate 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:

ts
// 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);

Ready to build?