Skip to content

Formatting helpers

Helpers for the text your bot sends, so a raw id or a millisecond count reaches the user as something readable. Covers snowflake timestamps, durations, big numbers, ranks, names a reader scans, random picks, and objects that won't stringify.

Your transport package re-exports @seedcord/utils. Everything here imports from @seedcord/gateway or @seedcord/http alongside the rest.

A snowflake carries its own timestamp

Every Discord id encodes the moment it was created, in the top 42 bits. timestampFromSnowflake reads it back off any id, a user's, a guild's, a message's, or an interaction's.

src/handlers/Ping.ts
import {
    SlashHandler,
    SlashRoute,
    timestampFromSnowflake
} from '@seedcord/gateway';

@SlashRoute('ping')
export class Ping extends SlashHandler<'ping'> {
    public async execute(): Promise<void> {
        const sent = timestampFromSnowflake(this.event.id);

        await this.reply(`Roundtrip ${Date.now() - sent}ms.`);
    }
}

The scaffold's /ping measures a roundtrip this way, storing nothing. Discord stamped the id when it built the interaction, so the difference against Date.now() is the time in flight.

Warning

timestampFromSnowflake passes the id to BigInt, which throws a SyntaxError on a string holding anything other than digits. Check the string first when the id came from user input.

Discord timestamps take seconds

<t:1735689600:R> renders as a live relative time in a message. Discord reads that number as seconds. JavaScript gives you milliseconds everywhere, so toEpochSeconds does the divide and the rounding.

const created = toEpochSeconds(timestampFromSnowflake(userId));
const line = `Account created <t:${created}:R>`;

currentTime returns now in the same unit, for a timestamp you build from scratch.

Duration strings

parseDuration turns '30m' into milliseconds. It's the same parser Cooldown uses, exported so your own options can take the same shape.

parseDuration('24h'); // 86400000
parseDuration('500ms'); // 500
parseDuration('1.5h'); // null
parseDuration('30M'); // null

The grammar is one or more digits followed by one lowercase unit, ms, s, m, h, or d. Everything else returns null, including a fraction, an uppercase unit, a bare number, and a duration of zero.

ValidDuration types a string in that shape. It accepts a fraction that the parser then rejects, so check the return.

Shortening a big number

An embed showing 12345678 makes someone count digits. roundToDenomination shortens it to a suffixed form like 12.3M.

roundToDenomination(999); // '999'
roundToDenomination(1234); // '1.2K'
roundToDenomination(999_999); // '1M'
roundToDenomination(12_345_678, { precision: 2 }); // '12.35M'
roundToDenomination(12_345_678, { suffixes: ['k'] }); // '12345.7k'

Anything below 1000 comes back unchanged. precision defaults to 1. suffixes replaces the default ['K', 'M', 'B', 'T', 'Q'] with a list of any length. Shortening stops at the last suffix you give it, which leaves five digits in front of the k above.

999,999 rounds to 1000K. A label reading 1000K looks wrong, so it carries to 1M.

Shortening costs you precision, since 1234 and 1240 both read as 1.2K. Keep the digits where the exact number is the point, as in a currency balance.

Ranks

ordinal adds the rank suffix a leaderboard prints. It covers 11th, 12th, and 13th, which a hand-rolled version usually gets wrong.

ordinal(1); // '1st'
ordinal(11); // '11th'
ordinal(21); // '21st'
ordinal(113); // '113th'

Names a reader scans

prettify turns an identifier into words. It reads camelCase, PascalCase, snake_case, and kebab-case, which is how seedcord prints ManageMessages as Manage Messages in a permission refusal.

prettify('ManageMessages'); // 'Manage Messages'
prettify('snake_case_string'); // 'snake case string'
prettify('kebab-case-string'); // 'kebab case string'
prettify('someLongOption', { capitalize: true }); // 'Some long option'

capitalize raises the first letter of the whole phrase and lowers the rest, which gives you a sentence. Title case takes your own pass over the words.

Picking at random

fyShuffle returns a shuffled copy and leaves the input alone. It runs Fisher-Yates, so every ordering is equally likely.

const [winner] = fyShuffle(entrants);

Tip

items.sort(() => Math.random() - 0.5) is the common way to write this by hand. It's measurably biased. A comparator has to give the same answer every time it sees a pair. That one answers at random on each call, which leaves some orderings far more likely than others.

generateCode returns a random number with exactly that many digits, never starting with a zero. It draws from Math.random(), which suits a pairing code. Someone who sees enough of its earlier outputs can predict the next one, so use node:crypto for a code that guards something.

Objects that won't stringify

discord.js structures point back at the client, which points back at them. JSON.stringify on one throws. filterCirculars returns a copy with each cycle replaced by a marker.

const safe = filterCirculars(payload);
const withMarker = filterCirculars(payload, { marker: '[loop]' });
const viaJson = filterCirculars(payload, { mode: 'json', logger });

mode picks how the copy is built. The default 'decycle' clones the value directly. 'json' runs stringify and parse, which lets a toJSON() method rewrite its own object while that runs. Both read own enumerable properties, so a getter defined on a class prototype is absent from the result.

Pass a logger, as the viaJson line does, to see why a copy failed. Without one the failure returns quietly as { '[unserializable]': ... }.

hasKeys checks a nested path and narrows the type when it passes.

if (hasKeys(report, ['author.profile.handle', 'score'])) {
    report.author.profile.handle.toUpperCase();
    report.score.toFixed(2);
}

Each string is a dot path, checked against null and undefined at every step. The narrowing covers every path you passed.

The rest

The reference lists every function in the package with its signature and its documentation.