--- name: "purrkit" description: "a meowing, fully-typesafe set of packages for interacting with the discord API" languages: ["TypeScript"] stub: false links: [ { type: "source", label: "source", href: "https://tangled.org/vt3e.cat/purrkit.git" }, { type: "website", label: "npm", href: "https://www.npmjs.com/package/@purrkit" }, ] messages: { author: "april", avatarUrl: "/avatar.webp", timestamp: "02:15", content: [ "im writing my own and its gonna be so clean and im gonna do type magic god i love doing type magic", "its gonanbe called KITTEN and its gonna MEOW", ], } --- import DiscordMessages from "../../components/DiscordMessages.astro";

what's a purrkit

purrkit fixes this™

i write a lot of discord stuff, and every time i started to write a new bot i'd end up writing the exact same helpers, command loaders and interaction handlers ooover and ooooover again. it got very tiring to do this, so i started looking around for some discord.js frameworks, the main two i found were [sapphire](https://sapphirejs.dev/) and [commandkit](https://commandkit.dev/). i did start out writing sapphire, it had a fair amount of niceities, but there's SO. MUCH. BOILERPLATE. why am i making an entire class just to listen for new messages . hoe come commands have a nice pretty central registry but for buttons, modals, and etc. you're on your own? did they just give up? also maybe i was stupid but it seemed to just freaking swallow errors. this is the point where i decided i would Write my Own framework . to quote verbatim what i said: at that point however a friend directed me to commandkit, i had a quick look and i did like it, what's really interesting is that they use JSX for components, i really liked that approach. but you were still on your own for handling options and such . and there was no type magic . im a big fan of type magic..

the type magic of slash options

"but i beloved non-null asserting Everything"

if you've written discord.js slash commands before then you know the pain of options. vanilla discord.js just gives you `interaction.options.getString("name")`, `getInteger(...)`, `getUser(...)`, and so on. the problem is this returns `(string | User | number | ...) | null`. doesn't matter that you configured the option as required, typescript has NO idea, discord.js ALSO has NO idea. so you're doing `?? ""` or slapping `!` on everything which is just a bit nasty isn't it. THEN multiply that by 5-6 options per command and it's, it's just pure misery. it's gross. purrkit fixes this™ by making you declare your options up front as a schema instead of imperatively fishing for them: ```ts const whois = kitten.command("whois", { description: "get info about a user", options: { user: option.user.required("the user"), ephemeral: option.boolean.optional("hide response"), }, async run(interaction, { user, ephemeral }) { // `user` is strictly typed as User // `ephemeral` is strictly typed as boolean | undefined }, }); ``` ### how it work i wrote a mapped type, `InferOptions`, that chews through your options object. checks if `required: true`, and if so rips out the inner type (`User`, `string`, whatever). if it's not required it unions with `undefined`. then at runtime kitten intercepts the command, walks the same schema, calls the right discord.js getter (`getUser`, etc), and just. hands you a plain object that matches the type EXACTLY. and it just works and it's beautiful as you can see above.

the stateless component problem

"how should i store this button's data" the convenient uri-encoded custom id:

discord components (so modals, select menus, modals and etc) are stateless lil kittens. all discord gives you is a `customId` string with up to 100 chars, which, to be fair, is probably how it should be. your options are pretty much: * generate a unique id for each component and store the state in redis or whatever, * or mash the state into a string like `ticket_close:ticketId`, untyped, and fragile, and breaks if your data has an underscore in it. purrkit however treats your components like commands, you provide an optiosn schema, and purrkit handles the encode/decode for you. ```ts const ticketBtn = kitten.button("close-ticket", { options: { ticketId: option.string() }, async run(interaction, { ticketId }) { await interaction.reply(`closing ticket ${ticketId}...`); }, }); const button = ticketBtn.button({ ticketId: "6767" }).setLabel("close"); ``` now this won't necesarrily cut it for all use cases, but those use cases i believe are fairly rare? i haven't encountered them, for example. i would like to implement custom \{de,\}serialisers for custom IDs in future. ### how it work call `.button({ ticketId: "6767" })` and purrkit walks your schema keys, `encodeURIComponent`s each value, joins with `:`. so you get `"close-ticket:6767"`. purrkit also conveniently checks the lengths of the resulting custom IDs, if they're larger than 100 chars, it\'ll throw a `CustomIdTooLong` error. when the click comes back in, kitten splits on the colon, reads the schema, casts everything back to proper types (`"true"` -> boolean, `"67"` -> number) and then hands it to your `run`.

mutating context without the `any`

believe it or not . purrkit fixes this™ too

instead of middleware fetching something and throwing it into the `interaction` object or whatever, like SOME web frameworks, purrkit accumulates it into a fully typed `context` object. the context accumulation here is basically stolen from koa (`ctx` threading through middleware) crossed with trpc's procedure builder (chaining `.use()`, narrowing types with each call). i wanted that shape for purrkit's middleware. ```ts const authed = kitten.builder().use(async (interaction) => { const dbUser = await db.users.find(interaction.user.id); if (!dbUser) throw new HaltExecution({ content: "unauthorized" }); return { dbUser }; }); authed.command("balance", { description: "check your balance", async run(interaction, args, ctx) { // ctx.dbUser is fully typed and guaranteed to exist here await interaction.reply(`balance: $${ctx.dbUser.balance}`); }, }); ``` ### how it work `CommandBuilder` is generic over its context, `CommandBuilder`. every `.use(middleware)` call looks at what your middleware returns (`NewCtx`) and hands you back a new builder typed as `CommandBuilder`. intersection after intersection, it accumulates, down the whole chain, like a snowball, or an onion, like an ogre. layers of types. so by the time `.command()` gets called, `ctx` in your `run` is the intersection of everything every middleware in that chain returned. with No Globals harmed in the making. but what if we need to stop! you can `throw new HaltExecution()`. kitten catches it, optionally fires a reply, and aborts the chain.

a rest client that doesn't throw

@purrkit/rest

outside the router i still needed to just. talk to the discord api directly sometimes. and most fetch wrappers `throw` on 400/500 which is SO annoying because an http error code is not always a code exception like, if i ask for a user and they deleted their account, a 404 is a completely valid expected state, not a catastrophe. i don't want to wrap every call in a giant `try/catch` and have my error variable collapse into `unknown` mush so, `@purrkit/rest`. discriminated unions, my beloved: ```ts import { RESTClient } from "@purrkit/rest"; const client = RESTClient({ token: process.env.DISCORD_TOKEN }); const user = await client.users.me.get(); // typescript forces you to check `.ok` before accessing `.data` if (user.ok) { console.log(user.data.username); // UserPIIResponse } else if (user.status === 429) { console.log(user.data.retry_after); // RatelimitedResponse } else { console.error(user.data.message); // ErrorResponse } ``` **how it works:** every method resolves to either `{ ok: true, status: 200, data: T }` or `{ ok: false, status: number, data: E }`. control flow analysis means `if (user.ok)` is a real type guard, inside it `data` is narrowed to the success shape, in the `else` it's narrowed to the error shape. no casting, no praying, typescript just KNOWS all the response types come straight from `@purrkit/types`, autogenerated off discord's own OpenAPI spec, so it's never out of sync with reality (unless discord lies to us, which, fair, has happened)