DEV Community

görkem
görkem

Posted on Fully Autonomous

Discord role positions aren't unique: the tie that quietly breaks your role checks

This article was written by an AI assistant (Claude) and reviewed by the bot's developer before publishing. The code comes from a production Discord bot and the behavior was checked against the Discord developer docs and the discord.js v14 source.

If you write role logic for a Discord bot, you have probably written this line at some point:

if (role.position < botMember.roles.highest.position) {
  await member.roles.add(role);
}
Enter fullscreen mode Exit fullscreen mode

It reads like the Permission Hierarchy rule from the docs. With discord.js Role objects it is correct, but only because the library does extra work for you. Role positions in the Discord API are not unique, and the moment the number comes from somewhere else, the same check can quietly make the wrong decision.

What the docs actually say

The Permission Hierarchy section of the Discord permissions docs states the rule bots live by:

A bot can grant roles to other users that are of a lower position than its own highest role.

The catch is in the Role Structure table, on the position field:

roles with the same position are sorted by id

So position is a sort key, not a rank. Two roles can carry the same position value, and Discord breaks the tie with the role ID. If your code only compares the number, every tie collapses into "equal". Depending on whether you wrote < or >=, an equal pair either always passes or always fails.

The docs don't spell out which direction the ID tie-break goes. discord.js implements it as "the lower ID ranks higher" (more on that below). Lower IDs are older snowflakes, so the role created first wins. We followed the same rule.

Does your library already handle it?

It depends on what object you are holding.

In discord.js v14, a Role has two fields:

  • rawPosition is the value the API sent.
  • position is a getter that counts how many cached roles sit below this one, breaking ties by ID.

Here is the getter, shortened from discord.js/src/structures/Role.js (v14.27):

get position() {
  return this.guild.roles.cache.reduce(
    (acc, role) =>
      acc +
      (this.rawPosition === role.rawPosition
        ? BigInt(this.id) < BigInt(role.id)
        : this.rawPosition > role.rawPosition),
    0,
  );
}
Enter fullscreen mode Exit fullscreen mode

RoleManager#comparePositions applies the same rule. So if you only ever compare discord.js Role objects through position or comparePositions, you are already safe.

You are not safe when the number comes from somewhere else:

  • raw JSON from a REST call you made with fetch,
  • role objects inside interaction payloads or raw gateway events,
  • rawPosition,
  • positions you stored in your own database, a backup or a snapshot,
  • a dashboard that renders the API's role list and decides which roles to grey out,
  • test fakes that set position directly.

That last one matters more than it sounds. If your tests build roles as { id, position } and never set two equal positions, the bug can't show up in CI.

One comparator, in one place

In our bot we found role comparisons spread across commands, the dashboard, the leveling, birthday and achievement code, and the server template code. We moved all of them to one small module that accepts any object with position and id, whether it is a discord.js Role, raw API JSON or a test fake. Shortened and with the names translated from the real file:

const hasPosition = (r) =>
  Boolean(r) && r.position != null && Number.isFinite(Number(r.position));
const hasId = (r) => r.id != null && /^\d+$/.test(String(r.id));

// > 0 if a ranks above b, < 0 if below, 0 if equal, null if not comparable.
function compare(a, b) {
  if (!hasPosition(a) || !hasPosition(b)) return null;
  const pa = Number(a.position);
  const pb = Number(b.position);
  if (pa !== pb) return pa - pb;          // different positions: position decides
  if (!hasId(a) || !hasId(b)) return 0;   // no ids: keep the old behavior
  const ia = BigInt(String(a.id));
  const ib = BigInt(String(b.id));
  if (ia === ib) return 0;
  return ib > ia ? 1 : -1;                // same position: lower id ranks higher
}

// Is `role` strictly below `top`? (Can the bot give it, edit it, sort it?)
function isBelow(role, top) {
  const c = compare(role, top);
  return c !== null && c < 0;
}
Enter fullscreen mode Exit fullscreen mode

A few decisions are worth calling out:

  • IDs are compared as BigInt. Snowflakes are larger than Number.MAX_SAFE_INTEGER. Comparing them as numbers or as strings of different lengths gives wrong answers.
  • Missing data is not "allowed". If a role has no usable position, compare returns null and isBelow returns false. The safe answer to "can I give this role?" when you don't know is "no".
  • Position still decides first. The ID only matters when positions are equal. That keeps the helper correct for discord.js Role objects too, where position is already unique.
  • No IDs means the old behavior. Some test fakes have no ID. Returning 0 keeps those tests meaningful instead of making them pass by accident.

The call site then reads like the rule in the docs:

if (!isBelow(role, botMember.roles.highest)) {
  // can't give this role (see the next section)
}
Enter fullscreen mode Exit fullscreen mode

Don't let it fail silently

The comparator makes the decision correct for any input. The bigger problem in our code was somewhere else: the old code skipped the role without telling anyone. Our original auto-role code looked like this:

const role = guild.roles.cache.get(targetRoleId);
if (role && role.position < guild.members.me.roles.highest.position) {
  await member.roles.add(role);
}
Enter fullscreen mode Exit fullscreen mode

That single if hides three different situations: the role was deleted, the role is above the bot, or the role is below the bot but roles.add failed. A server admin sees the same thing in all three cases: new members just don't get the role.

The new version records a reason for each case:

const role = guild.roles.cache.get(targetRoleId);
if (!role) return reportIssue(guild.id, 'autorole_assign', { code: 'role-missing' });

if (!isBelow(role, guild.members.me.roles.highest)) {
  return reportIssue(guild.id, 'autorole_assign', { code: 'hierarchy', role: role.name });
}

await member.roles.add(role)
  .then(() => clearIssue(guild.id, 'autorole_assign'))
  .catch((e) => reportIssue(guild.id, 'autorole_assign', { code: e.code }));
Enter fullscreen mode Exit fullscreen mode

reportIssue writes to a small table that the bot's dashboard reads, so the admin sees "the bot's role must be above Member" instead of guessing. Whatever you use (a log channel, a dashboard, a DM to the owner), the point is the same: when a bot decides not to do something it was configured to do, the decision should be visible.

Test the tie on purpose

The cases worth pinning down in tests:

role top (bot) expected isBelow
position 1, id 200 position 2, id 100 true (position decides)
position 1, id 200 position 1, id 100 true (tie, higher id is below)
position 1, id 100 position 1, id 200 false (tie, lower id is above)
position 1, id 100 position 1, id 100 false (same role)
no position position 1, id 100 false (unknown is not allowed)

The second and third rows are the ones a naive < check gets wrong, and the ones most test suites never build. It also helps to scan the code for raw position comparisons so new ones don't creep back in. We added a test that fails when someone writes role.position < … outside the helper, with an explicit allow list for snapshot code.

Summary

  • position in the Discord API is a sort key, not a unique rank. Ties are sorted by ID.
  • discord.js v14 normalizes Role#position for you. Raw JSON, rawPosition, stored snapshots and test fakes don't.
  • Put the comparison in one helper that understands ties, compares IDs as BigInt and treats missing data as "no".
  • When the answer is "no", say so somewhere a server admin can see it.

I ran into this while working on VeyroBot, a Discord server management bot that shows exactly this kind of failed action on its server health card.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.