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);
}
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.
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.
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,
);
}
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:
fetch,
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.
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;
}
A few decisions are worth calling out:
BigInt. Number.MAX_SAFE_INTEGER. Comparing them as numbers or as strings of different lengths gives wrong answers.compare returns null and isBelow returns false. The safe answer to "can I give this role?" when you don't know is "no".Role objects too, where 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)
}
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);
}
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 }));
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.
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.
Role#position for you. Raw JSON, 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.