@yaebal/callback-data

typed callback_data — pack and unpack button payloads with a compact, byte-aware wire format and full type inference.

install

terminal
pnpm add @yaebal/callback-data

usage

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.

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

pattern.ts
// 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.

evolution.ts
// 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 decodes

prefix-only schema

pass an empty schema {} for buttons that carry no payload:

prefix-only.ts
// a namespace with no fields — useful for simple action buttons
const ping = callbackData("ping", {});
ping.pack();         // => "ping"
ping.unpack("ping"); // => {}
ping.filter("ping"); // => true

fields

codecwire formTypeScript type
Number / field.number()base36 integer, decimal fallbacknumber
String / field.string()raw utf-8, only : / \ escapedstring
Boolean / field.boolean()1 / 0boolean
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 idsbigint

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

exportsignaturedescription
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>

membertypedescription
pack(data: InferInput<S>) => stringserialize a payload; throws if it exceeds the byte limit
unpack(raw: string) => InferOutput<S> | undefinedparse a raw string; undefined for any data that isn't valid for this namespace (never throws)
filter(raw: string | undefined) => booleancheap prefix check; safe to pass undefined
patternRegExpregex anchored to the prefix — pass to bot.callbackQuery()
extend(extra: E) => CallbackData<…>derive a new namespace with extra fields appended (immutable)
Telegram caps 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.