JAVASCRIPT / OPERATORS
Bitwise operators and when they still matter
Use JavaScript's bitwise operators on flag sets, packed integers and binary data, and know exactly where the 32-bit conversion makes bit tricks wrong.
What you will learn
- Set flags with |, test with &, clear with & ~mask, toggle with ^ on one integer
- Read x | 0 and ~~x as ToInt32: truncate toward zero, wrap outside 32 bits
- Parenthesize bitwise tests, since & and | bind looser than == and ===
- Use >>> 0 to read bit 31 as unsigned, and BigInt when 32 bits is not enough
Understanding Bitwise operators and when they still matter
A bitwise operator never sees the number you handed it. The operators &, |, ^, ~, << and >> first convert each operand with ToInt32: the fractional part is dropped toward zero and the value is wrapped modulo 2**32 into the range -2147483648 to 2147483647. The unsigned right shift >>> does the same but reads its left operand as unsigned instead. Every surprise on this topic comes from that conversion plus one extra rule: the shift count is masked with 31, so a shift of 32 is a shift of nothing.
The useful mental model is a row of 32 switches rather than a quantity. Using | turns switches on, & mask keeps only the switches present in the mask and reports whether any survived, ^ flips them, and & ~mask turns them off. Shifts slide the whole row sideways, which is why 1 << n is exactly the constant with bit n on and nothing else. Once you read the integer as a row of switches, flag code stops looking like arithmetic and starts reading like set operations.
Bitwise code still earns its place wherever the bits are the data rather than an optimization. MouseEvent.buttons, Node.compareDocumentPosition, WebGL clear masks and the mode field from fs.stat all hand you packed flags you have to mask apart, and parsing a binary format out of a DataView is shifting by nature. Packing three small values into one integer also gives you a cheap composite Map key or a flat Int32Array grid. What no longer earns its place is x >> 1 for halving or ~~x for flooring: engines compile the plain arithmetic just as well, and the 32-bit clamp turns those tricks into silent bugs as soon as a value passes 2147483647.
const READ = 1 << 0;
const WRITE = 1 << 1;
const EXEC = 1 << 2;
let perms = READ | WRITE; // grant read and write
perms |= EXEC; // add exec
perms &= ~WRITE; // clear write
console.log(perms.toString(2).padStart(4, '0'));
console.log((perms & READ) !== 0);
console.log((perms & WRITE) !== 0);
console.log(perms ^ EXEC);Bitwise operators do not act on your number, they act on a 32-bit signed integer copy of it, and every quirk they have follows from that conversion.
Worked examples
The 32-bit conversion, visible
Shows that the operands are truncated and wrapped into a signed 32-bit integer before any bit logic happens.
console.log(2 ** 31 | 0);
console.log(2 ** 32 | 0);
console.log(~~-3.7, Math.floor(-3.7));
console.log(1 << 32, 1 << 33);Example explained
Line 12 ** 31 is one past the largest positive int32, so ToInt32 wraps it into the negative half and | 0 returns -2147483648.
Line 22 ** 32 | 0 is 0 because the conversion is modulo 2**32, and 2**32 is congruent to 0.
Line 3~~-3.7 is -3 while Math.floor(-3.7) is -4: the conversion truncates toward zero, so it is not a floor.
Line 41 << 32 is 1 and 1 << 33 is 2 because the shift count is masked with 31, turning 32 into 0 and 33 into 1.
Precedence swallows the comparison
Demonstrates that & and | bind looser than equality, so an unparenthesized mask test quietly evaluates the wrong thing.
const n = 6;
console.log(n & 1 == 0);
console.log((n & 1) === 0);
console.log(1 | 2 === 2);
console.log((1 | 2) === 2);Example explained
Line 1n & 1 == 0 parses as n & (1 == 0), so it computes 6 & false, and false converts to 0.
Line 2That result is 0, which is falsy, so an if written this way never takes the branch and never throws.
Line 3(n & 1) === 0 is the intended form: mask first, then compare the masked value.
Line 41 | 2 === 2 hits the same trap and evaluates 1 | true, which is 1, while (1 | 2) === 2 is false because 1 | 2 is 3.
Packing three bytes into one integer
Packs and unpacks an RGB colour to show shifts placing values in fixed bit slots and masks pulling them back out.
const pack = (r, g, b) => (r << 16) | (g << 8) | b;
const unpack = v => [(v >> 16) & 255, (v >> 8) & 255, v & 255];
const c = pack(255, 128, 64);
console.log(c, c.toString(16));
console.log(unpack(c).join(','));
console.log(255 << 24);
console.log((255 << 24) >>> 0);Example explained
Line 1(r << 16) | (g << 8) | b moves each channel into its own 8-bit slot, and the ORs never collide because every channel is below 256.
Line 2(v >> 16) & 255 slides the red byte down to the bottom, and the mask throws away the higher bytes that rode along.
Line 3255 << 24 lands on bit 31, the sign bit, so the signed int32 result is -16777216 even though the bit pattern is correct.
Line 4Applying >>> 0 reinterprets those same 32 bits as unsigned and gives 4278190080.
Important notes
Every bitwise operator except >>> produces a signed int32, so any result with bit 31 set reads back negative; -1 >>> 0 is 4294967295, the identical bits viewed as unsigned.
BigInt supports & | ^ ~ << >> with no width limit but has no >>>, and mixing a BigInt with a Number inside one bitwise expression throws a TypeError.
Common mistakes
Using ~~x or x | 0 as a stand-in for Math.floor: ~~-3.7 is -3 rather than -4, and ~~3e9 is -1294967296, so negative or large values come out silently wrong.
Writing if (flags & FLAG == 0): equality binds tighter, the expression collapses to flags & 0, which is always falsy, so the branch is dead code that never errors.
Pushing flags past bit 30: 1 << 31 is -2147483648, which makes the whole flag set negative and prints with a minus sign from toString(2), and 1 << 32 is 1, silently colliding with the first flag.
Try it yourself
Change, predict, then run
In a browser console, keep a set of weekdays in a single integer with Monday as bit 0, add Monday, Wednesday and Saturday using |=, then remove Wednesday using &= ~, and log set.toString(2).padStart(7, '0') to confirm only bits 0 and 5 remain on.
Open the JavaScript workspaceCheck your understanding
Given const half = n => n >> 1, for which input does half(n) disagree with Math.floor(n / 2)?
- -7, because >> rounds toward zero on negative numbers
- 3000000000, because it does not fit in a signed 32-bit integer
- 7, because the bit shifted off the end changes the result
- Neither disagrees; n >> 1 and Math.floor(n / 2) always match
Show answer
3000000000 is converted with ToInt32 before the shift, becoming -1294967296, so half returns -647483648 while Math.floor(3000000000 / 2) is 1500000000. The tempting answer -7 is wrong because >> is an arithmetic shift that copies the sign bit and therefore rounds toward negative infinity, exactly like Math.floor: both -7 >> 1 and Math.floor(-7 / 2) give -4.