{"slug": "discord-role-positions-aren-t-unique-the-tie-that-quietly-breaks-your-role", "title": "Discord role positions aren't unique: the tie that quietly breaks your role checks", "summary": "A developer documented a subtle bug in Discord bot role logic: Discord API role positions are not unique, and ties are broken by role ID (lower ID ranks higher), so code that compares only the numeric position can silently make the wrong permission decision. The fix centralizes comparisons in a module that accepts any object with position and id, applying the ID tie-break with BigInt and returning null for non-comparable inputs.", "body_md": "*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.*\n\nIf you write role logic for a Discord bot, you have probably written this line at some point:\n\n```\nif (role.position < botMember.roles.highest.position) {\n  await member.roles.add(role);\n}\n```\n\nIt 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.\n\nThe Permission Hierarchy section of the [Discord permissions docs](https://docs.discord.com/developers/topics/permissions) states the rule bots live by:\n\nA bot can grant roles to other users that are of a lower position than its own highest role.\n\nThe catch is in the Role Structure table, on the `position` field:\n\nroles with the same position are sorted by id\n\nSo `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.\n\nThe 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.\n\nIt depends on what object you are holding.\n\nIn discord.js v14, a `Role` has two fields:\n\n`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.\nHere is the getter, shortened from `discord.js/src/structures/Role.js` (v14.27):\n\n``` js\nget position() {\n  return this.guild.roles.cache.reduce(\n    (acc, role) =>\n      acc +\n      (this.rawPosition === role.rawPosition\n        ? BigInt(this.id) < BigInt(role.id)\n        : this.rawPosition > role.rawPosition),\n    0,\n  );\n}\n```\n\n`RoleManager#comparePositions` applies the same rule. So if you only ever compare discord.js `Role` objects through `position` or `comparePositions`, you are already safe.\n\nYou are **not** safe when the number comes from somewhere else:\n\n`fetch`,\nThat 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.\n\nIn 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:\n\n``` js\nconst hasPosition = (r) =>\n  Boolean(r) && r.position != null && Number.isFinite(Number(r.position));\nconst hasId = (r) => r.id != null && /^\\d+$/.test(String(r.id));\n\n// > 0 if a ranks above b, < 0 if below, 0 if equal, null if not comparable.\nfunction compare(a, b) {\n  if (!hasPosition(a) || !hasPosition(b)) return null;\n  const pa = Number(a.position);\n  const pb = Number(b.position);\n  if (pa !== pb) return pa - pb;          // different positions: position decides\n  if (!hasId(a) || !hasId(b)) return 0;   // no ids: keep the old behavior\n  const ia = BigInt(String(a.id));\n  const ib = BigInt(String(b.id));\n  if (ia === ib) return 0;\n  return ib > ia ? 1 : -1;                // same position: lower id ranks higher\n}\n\n// Is `role` strictly below `top`? (Can the bot give it, edit it, sort it?)\nfunction isBelow(role, top) {\n  const c = compare(role, top);\n  return c !== null && c < 0;\n}\n```\n\nA few decisions are worth calling out:\n\n`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.\nThe call site then reads like the rule in the docs:\n\n```\nif (!isBelow(role, botMember.roles.highest)) {\n  // can't give this role (see the next section)\n}\n```\n\nThe 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:\n\n``` js\nconst role = guild.roles.cache.get(targetRoleId);\nif (role && role.position < guild.members.me.roles.highest.position) {\n  await member.roles.add(role);\n}\n```\n\nThat 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.\n\nThe new version records a reason for each case:\n\n``` js\nconst role = guild.roles.cache.get(targetRoleId);\nif (!role) return reportIssue(guild.id, 'autorole_assign', { code: 'role-missing' });\n\nif (!isBelow(role, guild.members.me.roles.highest)) {\n  return reportIssue(guild.id, 'autorole_assign', { code: 'hierarchy', role: role.name });\n}\n\nawait member.roles.add(role)\n  .then(() => clearIssue(guild.id, 'autorole_assign'))\n  .catch((e) => reportIssue(guild.id, 'autorole_assign', { code: e.code }));\n```\n\n`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.\n\nThe cases worth pinning down in tests:\n\n| role | top (bot) | expected `isBelow` | \n|---|---|---|\n| position 1, id 200 | position 2, id 100 | `true` (position decides) | \n| position 1, id 200 | position 1, id 100 | `true` (tie, higher id is below) | \n| position 1, id 100 | position 1, id 200 | `false` (tie, lower id is above) | \n| position 1, id 100 | position 1, id 100 | `false` (same role) | \n| no position | position 1, id 100 | `false` (unknown is not allowed) | \n\nThe 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.\n\n`Role#position` for you. Raw JSON, I ran into this while working on [VeyroBot](https://veyrobot.xyz/en?utm_source=devto&utm_medium=article), a Discord server management bot that shows exactly this kind of failed action on its server health card.", "url": "https://wpnews.pro/news/discord-role-positions-aren-t-unique-the-tie-that-quietly-breaks-your-role", "canonical_source": "https://dev.to/holyleth/discord-role-positions-arent-unique-the-tie-that-quietly-breaks-your-role-checks-3p8a", "published_at": "2026-10-09 20:45:48+00:00", "updated_at": "2026-10-09 20:54:31.056461+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Discord", "discord.js", "Claude"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/discord-role-positions-aren-t-unique-the-tie-that-quietly-breaks-your-role", "markdown": "https://wpnews.pro/news/discord-role-positions-aren-t-unique-the-tie-that-quietly-breaks-your-role.md", "text": "https://wpnews.pro/news/discord-role-positions-aren-t-unique-the-tie-that-quietly-breaks-your-role.txt", "jsonld": "https://wpnews.pro/news/discord-role-positions-aren-t-unique-the-tie-that-quietly-breaks-your-role.jsonld"}}