Sometimes you want to perform bit operations in JavaScript, and due to it's mutating nature, it's easy to get in a muddle. I recently wanted to run a bitwise not
on 0FF
(cyan) expecting F00
(red) and of course that's not what I saw, so I've written up (so I remember) how it works.
UK EVENTAttend ffconf.org 2024
The conference for people who are passionate about the web. 8 amazing speakers with real human interaction and content you can't just read in a blog post or watch on a tiktok!
£249+VAT - reserve your place today
Bits
Everything is represented computationally as a bit. A bit is a 0
or 1
. Character, for instance, are typically 8 bits. The letter R
has a decimal value of 82
(via "R".charCodeAt(0)
), which has a hex value of 0x52
and a binary value of 0b1010010
. Although the binary shows as 7 bits, it's more useful to think of it as 8 bits AKA 1 byte (or certainly in my case).
Note the preceding 0x
means hex, and 0b
means binary (off topic, but octal is 0o
).
So, my (cyan) value of 0x0FF
can be represented as 4 0
s and two sets of 4 1
s: 0b000011111111
. Let's look at bitwise not'ing the value.
Bitwise not
The not operation will invert the binary value (example taken from MDN):
a | NOT a |
---|---|
0 | 1 |
1 | 0 |
In JavaScript this is represented as ~ 0b1
(where 1
is the binary value I want to invert).
Here's when things get hairy. Running ~ 0b1
in JavaScript yields -2
:
What is a number?
In the example above, I expected to see 0
as the result, but I see -2
. Why is that?
There's a few things going on that are initially misleading. First of all, the result is a decimal, not binary. In all the REPLs I've come across (devtools' console, jsconsole, etc) show the results as a Number
type. Numbers are decimal in JavaScript.
Knowing that numbers are decimal isn't quite enough though. Numbers in JavaScript are double-precision floating-point format occupying 64 bits (or 8 bytes).
If I transform the result to binary, we can get a better idea of what's going on:
A quick breakdown:
n >>> 0
is a zero-fill right which will (if I understood correctly) drop the sign on the integer which results in a large decimal value (i.e. -1 in binary is all1
values including the far left sign bit).Number.toString(2)
sets the radix to base 2 (binary) and returns a stringpadStart(64, sign)
uses the ES6 padStart (aka pad left) with the sign bit (if our number was negative, the sign is1
otherwise0
) and fills the left part of the string until the length is 64 characters (or to us: 64 bits)
Why does not 1 equal minus 2?
Now that we have a better understanding of 64 bit numbers, we can see that inverting 0b1
is actually inverting the first 63 zero bits and then the final one bit. Because the sign bit is also flipped, the result is 63 one bits and one zero bit: -2
.
What we really want is some kind of clamping on our bits. Here we can use typed arrays to help.
Typed arrays for unsigned bytes
Creating a new Uint8Array
will give us an array that holds unsigned bytes in each array element. The unsigned is key getting the not operation to work the way we expect. So 0b1
will look like 00000001
and flipping correctly will be 11111110
.
Keeping things simple, I'll create a new typed array with single element containing the 0b1
value from earlier. Now, with this unsigned byte, running the not operation yields the same result in the console, but the stored result is actually as expected:
Flipping #0FF
My original requirements were also hugely flawed. I had hoped to flip red to cyan with the assumption that #0FF
would flip to #F00
. I spotted my mistake though. The hex number is 0xFF
(or leading zeros, as with decimal, aren't required), if we pad left, the value is 0x0000FFFF
.
That value isn't red at all. In fact, in most browsers (at time of writing) don't support 8 digit hex values (although apparently Firefox does and so does Chrome Canary). This colour (in 8 digit hex) is blue!
The next job would be to put this value into the typed array, but now the Unit8Array
is too small. Each array element in the Uint8Array
can be 8 bits, the value in hex is 32 bits (including the leading zeros). I could switch out to using the Uint32Array
and repeat the process from eariler, and it would work, but it feels…clunky. To the point where I'm realising that the entire premise was flawed.
But now I've learnt all about bitwise not, the answer to my original problem lies in using the bitwise XOR.
XOR…or: what I should have done
This was a fun dive into understanding the not operator, but if I had really just wanted to flip red to cyan, XOR against 0xFFF
would have been the way.
For a XOR b
, XOR yields binary 1
if a and b are different, and binary 0
if they're the same. Thus generating an inverted result. So, a bitwise XOR against 0xFFF
will always flip every individual bit, and importantly, it will leave the sign bit alone (i.e. we won't suddenly get negative values).
Well, that was fun. I've crawled out of my rabbit hole, and returning to work. I hope it was fun for you too!