# Custom IDs

Declare what a component's custom id carries with CustomId, so a click arrives with typed values. Covers minting the string, the field kinds, reading an id back by hand, and clicks the router should skip.

Discord hands a component's `custom_id` back when someone clicks a button, picks from a select menu, or submits a modal. It's always a plain string, and whatever your handler reads later has to be inside it.

Written by hand, that's a string like `ticket:184580573574955008:close:true` that you build in one file and split apart in another. Every value comes back as text, so turning `'true'` back into a boolean is your job too. The two sides agree on the order only because you kept them matching by hand. Add a field to one side and the other reads the wrong piece, with no error until someone clicks.

[`CustomId`](https://docs.seedcord.org/packages/custom-id/latest/custom-id) declares what the string carries, once. The component that encodes it and the handler that decodes it both read that one declaration, so a field you add reaches both sides. The `AppealId` on the button from [building components](/components) is one of these.

```ts title="src/components/TicketActions.ts"
import { CustomId } from '@seedcord/gateway';

export const TicketAction = new CustomId('ticket')
    .snowflake('ownerId')
    .oneOf('action', ['close', 'reopen'])
    .bool('notify');
```

`'ticket'` is the prefix, which seedcord routes a click by. Each call after it adds one field. Together those fields type the values `encode` takes and `decode` returns.

```txt output
{ ownerId: '184580573574955008', action: 'close', notify: true }
                        │
                 encode │
                        ▼
              ticketuFh:o_DDcBfgAB
              └──┬─┘└┬┘ └────┬───┘
                 │   │       └ the three values
                 │   └ shape hash
                 └ prefix
                        │
                 decode │
                        ▼
{ ownerId: '184580573574955008', action: 'close', notify: true }
```

> **Warning**
>
> Each method returns a new `CustomId` and leaves the original untouched. A call whose result you don't keep adds nothing, which is why every declaration on this page is one chain.
>
> ```ts
> const Broken = new CustomId('ticket');
> // the result goes nowhere and Broken keeps zero fields
> Broken.snowflake('ownerId');
> ```

## Minting the string

`encode` takes one value per field and returns the wire string.

```ts title="src/components/TicketActions.ts"
export class TicketActions extends RowComponent<'button'> {
    constructor(ownerId: string) {
        super('button');

        const close = TicketAction.encode({
            ownerId,
            action: 'close',
            notify: true
        });

        this.instance.addComponents(
            new ButtonBuilder()
                .setCustomId(close)
                .setLabel('Close')
                .setStyle(ButtonStyle.Danger)
        );
    }
}
```

The chain types every value, so your editor offers the two choices `oneOf` declared for `action`.

```ts
TicketAction.encode({ ownerId: '123', action: '
```

`'archive'` below isn't one of the choices, so the build stops, and leaving out a field like `notify` stops it too.

```ts
TicketAction.encode({ ownerId: '123', action: 'archive' });
```

The type can't check a value's range or format. `900` compiles against `int('page', 1, 50)`, and so does `'abc'` for a `snowflake`. `encode` throws on both when it runs.

## The field kinds

The kind you pick sets the type your handler gets back and how many characters the value takes. Pick the narrowest kind that fits.

{/* prettier-ignore-start */}

| method                  | decodes to                    | on the wire   |
| ----------------------- | ----------------------------- | ------------- |
| `snowflake(name)`       | `string`                      | packs         |
| `uuid(name)`            | `string`                      | packs         |
| `int(name, min, max)`   | `number`                      | packs         |
| `int(name)`             | `number`                      | its own token |
| `bool(name)`            | `boolean`                     | packs         |
| `oneOf(name, choices)`  | the literal union             | packs         |
| `someOf(name, choices)` | an array of the literal union | packs         |
| `str(name)`             | `string`                      | its own token |

{/* prettier-ignore-end */}

`oneOf` reads its choices as literals with no `as const`, so `choice` decodes as that union.

```ts
const Vote = new CustomId('vote')
    .uuid('pollId')
    .oneOf('choice', ['yes', 'no', 'abstain'])
    .int('weight', 1, 10);

const { choice } = Vote.decode('...');
```

`someOf` carries any subset of its list, which suits a select menu that allows several picks.

```ts
const Assign = new CustomId('assign').someOf('roles', [
    'reader',
    'artist',
    'vip'
]);

const wire = Assign.encode({ roles: ['vip', 'reader', 'vip'] });

const { roles } = Assign.decode(wire); // ['reader', 'vip']
```

The `'vip'` passed twice comes back once. `decode` returns the picks in the order the list declares them, which puts `'reader'` first.

### Fields that can be absent

A `/leaderboard [role]` command takes a filter the caller can leave out, and every page button has to carry that filter either way. Every kind takes `{ nullable: true }` for a value like that, which decodes the field as `T | null`.

```ts
const Board = new CustomId('board')
    .int('page', 0, 999)
    .snowflake('roleId', { nullable: true });

const { roleId } = Board.decode('...');
```

When the caller leaves the role out, pass `roleId: null` to `encode`. Each button then decodes `roleId` as `null`, which your handler reads as no filter.

> **Warning**
>
> Marking a field nullable changes the shape, which changes the hash in the route key. Every button already on a message refuses with `StaleCustomId` on the next click. The user gets a card saying so, and [that card is yours to replace](/components/stale).

### What a field costs

Discord caps a `custom_id` at 100 characters, and that covers the prefix, the shape hash, and every value.

A packing field has a known count of possible values. seedcord multiplies those counts into one integer and writes it in base64. A nullable field adds one to its own count, for the absent case.

```txt output
ownerId  184580573574955008   2^64 possible values
action   'close'                 2 possible values
notify   true                    2 possible values

     multiplied into one integer, written in base64
                          │
                          ▼
                     o_DDcBfgAB    10 characters
```

A token field sits after that number behind a separator. `str` writes its text as given, which makes it the expensive kind. Avoid it where another kind fits.

Once a wire runs past 100, `encode` throws `CustomIdWireTooLong`. You call `encode` while you build the component, so you get that throw before anyone can click.

> **Tip**
>
> Bound an integer whenever you know its range. `int('page')` writes its own token. `int('page', 0, 500)` packs into the shared number with everything else and makes a shorter wire. `oneOf`, `someOf`, and `bool` pack in the same way.

## Reading one back yourself

A handler decodes the id for you, as [Buttons](/components/buttons) shows. Code outside a handler, like a discord.js collector, gets only the raw string, so you decode it yourself.

```ts
declare const wire: string;

if (TicketAction.owns(wire)) {
    const { ownerId, action } = TicketAction.decode(wire);
}

const Poll = new CustomId('poll').uuid('pollId');

const { prefix, params } = decodeFor([TicketAction, Poll], wire);
```

`owns` compares the prefix and ignores everything after it. `decode` throws on a matching prefix with an old hash, which means [the id went stale](/components/stale). It also throws on a corrupt string, or one minted by another `CustomId`.

[`decodeFor`](https://docs.seedcord.org/packages/custom-id/latest/decode-for) tries each id in the array against the string. It returns the `prefix` that matched with that id's own `params`, and checking `prefix` narrows `params` to match.

> **Danger**
>
> A custom id you write by hand compiles. `setCustomId('ticket:123')` skips the shape hash `encode` adds, so seedcord reads that prefix as `tic`. No handler matches the click. Mint every id through `encode`.

> **Danger**
>
> `CustomId` ships in `@seedcord/custom-id`, and your transport package re-exports it. Import it from `@seedcord/gateway` or `@seedcord/http`. Adding `@seedcord/custom-id` to your own dependencies can resolve a second copy, and a decode failure from that copy skips the [rendered notices](/components/stale) seedcord provides.

## Clicks the router should skip

A discord.js collector you opened yourself answers the clicks it waits for. seedcord's router gets those same clicks too. With no handler registered for them, it answers with its unhandled reply. Discord then rejects whichever answer comes second. List the id under `ignoreCustomIds` to make the router return before it answers.

```ts title="src/bot.ts"
export const seedcord = new Seedcord({
    bot: {
        clientOptions: { intents: [GatewayIntentBits.Guilds] },
        interactions: {
            path: resolve(import.meta.dirname, './handlers'),
            ignoreCustomIds: [Poll]
        },
        commands: { path: null },
        events: { path: null }
    },
    subscribers: { path: null }
});
```

A click whose prefix `Poll` owns never reaches a handler. The list takes `CustomId` values. A raw string fails to compile, since seedcord matches each entry by calling `owns`.

> **Gateway only**
>
> An http bot does not read `ignoreCustomIds`. Discord posts every
> interaction to one endpoint, and the router dispatches all of
> them.
