DEV Community

BeGoodTool.com
BeGoodTool.com

Posted on

Why signed binary ranges are asymmetric (I only really got it after building a converter)

I've "known" two's complement since a CS101 lecture — flip the bits, add one, done. But I didn't really internalize the range-checking part until I tried to build a small calculator that converts between decimal and N-bit binary (showing 1's and 2's complement side by side), and had to handle arbitrary bit widths instead of the fixed int8/int16/int32 everyone just memorizes.

The part everyone skips: range checking

For an N-bit signed number, the range is asymmetric: -2^(N-1) to 2^(N-1) - 1. For 8 bits that's -128 to 127, not -127 to 127. The reason: there's only one all-zero bit pattern (that's 0), so the "extra" negative slot that would otherwise be wasted on a redundant zero goes to the most negative number instead.

That asymmetry means "does this number fit in N bits" isn't a simple abs(num) <= 2^(N-1) - 1 check — the bound is different depending on sign:

function limitNum(num, numLen) {
  const baseNum = 2 ** (numLen - 1);
  const maxNum = baseNum - 1;   // 127 for 8 bits
  const minNum = 0 - baseNum;   // -128 for 8 bits
  return num > maxNum || num < minNum ? 0 : 1;
}
Enter fullscreen mode Exit fullscreen mode

Miss this and your calculator will correctly accept -128 in 8 bits, but also let +128 slide through as if it fit — or worse, silently wrap around and hand back a nonsense value instead of telling the user it's out of range.

Flip the bits, then add one — except "add one" isn't trivial in string-land

Once a number is in binary, its two's complement is "invert every bit, then add 1." Trivial with actual integers — but I was storing binary as strings (so I could show arbitrary bit widths padded with leading zeros), which means "add 1" has to be done by hand, propagating the carry bit by bit from the right:

function notString(binary) {
  return binary.split("").map(bit => (bit === "0" ? "1" : "0")).join("");
}

function addOne(binary) {
  const bits = binary.split("");
  for (let i = bits.length - 1; i >= 0; i--) {
    if (bits[i] === "1") {
      bits[i] = "0"; // carry the 1, keep going left
    } else {
      bits[i] = "1"; // found a 0, flip it, carry absorbed, done
      return bits.join("");
    }
  }
  return bits.join(""); // all 1s overflowed — technically wraps around
}
Enter fullscreen mode Exit fullscreen mode

This is exactly what a hardware full-adder does when adding 1 to a binary number: propagate the carry until it hits a bit that stops it. Writing it out in string form makes visible something that's normally hidden inside the + operator.

Going backwards is the same trick, reversed

The calculator also needs to go the other direction — from a two's complement bit pattern back to the original magnitude, which is useful when you're staring at a hex dump and need to know what negative number it actually represents. It's the same two operations, just chained to get back to source:

function fromTwosComplement(complement) {
  const inverted = complement.split("").map(bit => (bit === "0" ? "1" : "0")).join("");
  return (parseInt(inverted, 2) + 1).toString(2);
}
Enter fullscreen mode Exit fullscreen mode

Invert, add one, done. Two's complement is its own inverse operation, which is a big part of why nearly every CPU uses it: subtraction becomes addition of a complement, so there's no separate circuitry needed just for negative numbers.

Where I actually use this

I don't reach for a two's complement calculator often, but the few times a year I do — debugging a serialization bug, or double-checking a bitwise op in a language with unfamiliar int-width rules — it's genuinely useful to see binary, 1's complement, 2's complement, and decimal side by side instead of working it out by hand or trusting a REPL's implicit int width. I cleaned up the version I built for that into a small free tool: Two's Complement Calculator. No sign-up, works for any bit width you throw at it.


Available in other languages

Top comments (0)