@yaebal/callback-data
typed callback_data — pack and unpack button payloads with a compact, byte-aware wire format and full type inference.
install
pnpm add @yaebal/callback-datausage
call callbackData(prefix, schema) once per namespace. bare Number, String and Boolean are shorthand for required
scalars; the field builders add enums, optionals and defaults. types are
inferred — pack, unpack and ctx.queryData all carry
the exact shape.
import { Bot } from "@yaebal/core";
import { InlineKeyboard } from "@yaebal/keyboard";
import { callbackData, field } from "@yaebal/callback-data";
// a namespace: a prefix + a field schema
const user = callbackData("user", {
id: Number, // required scalar (shorthand)
action: field.enum(["ban", "kick", "mute"]), // union type, one char on the wire
note: field.string().optional(), // may be absent
page: field.number().default(1), // absent → filled with 1
});
const bot = new Bot(process.env.BOT_TOKEN!);
bot.command("manage", (ctx) =>
ctx.reply("choose:", {
reply_markup: new InlineKeyboard()
.text("ban", user.pack({ id: 42, action: "ban" }))
.text("kick", user.pack({ id: 42, action: "kick", note: "spam" }))
.build(),
}),
);
// pass the namespace itself: the payload is validated + decoded to ctx.queryData,
// and this handler runs only on a clean unpack
bot.callbackQuery(user, (ctx) => {
const { id, action, page } = ctx.queryData; // fully typed
return ctx.reply(`${action} ${id} (page ${page})`);
});routing on the raw pattern
passing the namespace to callbackQuery is the typed path. when you need the raw
regex instead, route on .pattern and call .unpack yourself:
// prefer passing the namespace (typed ctx.queryData). when you need the raw regex —
// e.g. to route several namespaces through one handler — use .pattern + .unpack:
bot.callbackQuery(user.pattern, (ctx) => {
const payload = user.unpack(ctx.callbackQuery.data ?? "");
if (!payload) return; // foreign or outdated data
// payload is fully typed
});schema evolution
appending an optional field — or appending a member to an field.enum — is backward-compatible: buttons packed before the change still unpack. adding a required field, reordering, or changing a field's type breaks
old buttons.
// appending an optional field (or an enum member) keeps old buttons working
const v1 = callbackData("p", { id: Number });
const v2 = callbackData("p", { id: Number, note: field.string().optional() });
v2.unpack(v1.pack({ id: 7 })); // => { id: 7 } — a button packed by v1 still decodesprefix-only schema
pass an empty schema {} for buttons that carry no payload:
// a namespace with no fields — useful for simple action buttons
const ping = callbackData("ping", {});
ping.pack(); // => "ping"
ping.unpack("ping"); // => {}
ping.filter("ping"); // => truefields
| codec | wire form | TypeScript type |
|---|---|---|
Number / field.number() | base36 integer, decimal fallback | number |
String / field.string() | raw utf-8, only : / \ escaped | string |
Boolean / field.boolean() | 1 / 0 | boolean |
field.enum([...]) | member index (base36) | union of the members |
field.uuid() | 22 base64url chars (down from 36) | string |
field.bigint() | base36 — lossless for 64-bit ids | bigint |
any field chains .optional() (absent → undefined on unpack) and .default(v) (absent → v on unpack, optional in the packed input).
field.uuid() is for database keys (postgres/supabase/prisma defaults): a raw
uuid is 36 bytes — over half the budget — so it's repacked into 16 raw bytes + base64url. field.number() refuses integers beyond Number.MAX_SAFE_INTEGER (they can't round-trip exactly through a js number); field.bigint() is the lossless home for 64-bit ids.
api
| export | signature | description |
|---|---|---|
callbackData | (prefix, schema, options?) => CallbackData<S> | creates a typed callback_data namespace; options.maxBytes overrides the 64-byte guard |
field | { string, number, boolean, enum, uuid, bigint } | field builders, each chainable with .optional() / .default(v) |
CallbackData<S>
| member | type | description |
|---|---|---|
pack | (data: InferInput<S>) => string | serialize a payload; throws if it exceeds the byte limit |
unpack | (raw: string) => InferOutput<S> | undefined | parse a raw string; undefined for any data that isn't valid for this namespace (never throws) |
filter | (raw: string | undefined) => boolean | cheap prefix check; safe to pass undefined |
pattern | RegExp | regex anchored to the prefix — pass to bot.callbackQuery() |
extend | (extra: E) => CallbackData<…> | derive a new namespace with extra fields appended (immutable) |
callback_data at 64 bytes. the wire format is
built to be small — base36 numbers, one-byte booleans, enum indices, raw utf-8 strings — and pack throws early if a payload still overflows. the prefix must not contain
: or \ — both are
reserved by the wire format, and callbackData throws immediately if it does. routing on the namespace runs the handler only on a clean unpack, so there is no gap between
filter and unpack — outdated or hostile data simply
falls through.