🍱 Lunchbox Hands

explainer

Bit Masks and Flags: The Bitwise Tricks Every Developer Should Know

Set, clear, toggle and test bits, x & (x - 1), x & -x, packing RGB565 and Unix mode bits, and the JavaScript 32-bit traps that break masks above bit 30.

Most bitwise code in the wild is the same four lines, copied between projects: set a bit, clear it, flip it, test it. Every bit trick is one of a few masks (a value with ones exactly where you want to act) combined with AND, OR or XOR, and nearly every bug comes from the mask being the wrong width. This post covers the four basic moves, the two classic one-liners built on x - 1, packing fields with shifts, and the JavaScript rules that quietly break masks at bit 31 and up.

Every value below was computed twice: once in Node 22 and once through the code behind our bitwise calculator. You can type any of these examples into it and watch the bits change. How negative numbers are stored isn’t covered again here; that’s in two’s complement explained. This post assumes it.

The four moves

Give each flag one bit by writing it as 1 << n:

const READ   = 1 << 0  // 0b0001
const WRITE  = 1 << 1  // 0b0010
const EXEC   = 1 << 2  // 0b0100
const HIDDEN = 1 << 3  // 0b1000

let flags = READ | EXEC  // 5, 0b0101
MoveCodeResult from 0b0101Why it works
Setflags | WRITE0b0111 (7)OR with 1 forces a bit on; OR with 0 changes nothing
Clearflags & ~EXEC0b0001 (1)~EXEC has a 1 everywhere except bit 2, so AND keeps every bit except that one
Toggleflags ^ HIDDEN0b1101 (13)XOR with 1 flips a bit; doing it twice gives back 5
Test(flags & EXEC) !== 0trueAND clears every bit except the one you asked about

The test row has a subtlety once a mask has more than one bit. With RW = READ | WRITE, (flags & RW) !== 0 asks “is any of these set?” (true here: READ is set), while (flags & RW) === RW asks “are all of them set?” (false: WRITE isn’t). Mixing these two up is a common source of permission bugs. Pick one on purpose and name the helper after it (hasAny, hasAll).

To try the clear move in our calculator, set width 8, operation AND, A = 0b0101, and B = 0b11111011. B can also be typed as -5, because ~4 is -5 (~x is always -x - 1) and the tool stores negative input as its two’s-complement bits at the chosen width. Both forms produce the same B grid, 1111 1011, and the same result, 0000 0001.

x & (x - 1): clear the lowest set bit

Subtracting 1 from a number turns its lowest 1 bit into 0 and every 0 below it into 1. Nothing above that bit changes. AND the result with the original and the lowest set bit is gone:

x       = 0110 1000   (104)
x - 1   = 0110 0111   (103)
x&(x-1) = 0110 0000   (96)

Two well-known idioms follow from this.

Power-of-two check. A power of two has exactly one bit set, so clearing it leaves zero:

const isPow2 = x => x !== 0 && (x & (x - 1)) === 0
isPow2(64)    // true  (0100 0000 & 0011 1111 = 0)
isPow2(96)    // false
isPow2(0)     // false, which is why the x !== 0 guard is there

Counting set bits. Each pass clears one set bit, so the loop runs once per 1 bit, not once per bit position:

function popcount(x) { let c = 0; while (x) { x &= x - 1; c++ } return c }
popcount(104)  // 3
popcount(255)  // 8

Our calculator has no subtract operation. You compute x - 1 yourself and AND the two: A = 104, B = 103 gives 0x60. The result panel shows set bits (popcount), leading zeros and trailing zeros for every result, so you don’t need the loop to check your answer. For the 0x60 above they read 2, 1 and 5 at 8 bits. To check 104 itself, OR it with 0: you get 3, 1 and 3.

x & -x: isolate the lowest set bit

In two’s complement -x is ~x + 1. Inverting flips every bit, and adding 1 carries back up to exactly the position of the lowest set bit. So x and -x agree on that one bit and on nothing above it:

x       = 0110 1000   (104)
-x      = 1001 1000   (-104 at 8 bits)
x & -x  = 0000 1000   (8)

It is also the quick way to find the largest power of two that divides x. In our tool, set A = 104 and B = -104 with AND. B’s grid shows 1001 1000 and the result is 0x08, which has 3 trailing zeros. That is the same number you’d get from a count-trailing-zeros instruction.

Packing fields with shifts and masks

Masks also let you fit several small numbers into one integer. A good real example is RGB565, the 16-bit pixel format. Android’s Bitmap.Config.RGB_565 documentation describes it like this: “red is stored with 5 bits of precision (32 possible values), green is stored with 6 bits of precision (64 possible values) and blue is stored with 5 bits of precision”. It also gives the packing formula:

short color = (R & 0x1f) << 11 | (G & 0x3f) << 5 | (B & 0x1f);

That reads as: mask each channel to its field width, shift it to its field’s position, and OR the fields together. Unpacking is the same steps in reverse: shift the field down to bit 0, then mask off everything above it.

const pack = (r, g, b) => ((r & 0x1f) << 11) | ((g & 0x3f) << 5) | (b & 0x1f)

// 8-bit color (255, 128, 64) -> drop low bits to fit 5/6/5
const c = pack(255 >> 3, 128 >> 2, 64 >> 3)   // pack(31, 32, 8)
c.toString(16)                                // 'fc08'  (64520)

const r = (c >> 11) & 0x1f   // 31
const g = (c >> 5)  & 0x3f   // 32
const b =  c        & 0x1f   // 8

Each step can be done separately in our calculator at 16 bits:

StepOperationResult
Red into place31 SHL 110xF800 (1111 1000 0000 0000)
Green into place32 SHL 50x0400
Combine0xF800 OR 0x04080xFC08
Green back out0xFC08 SHR 50x07E0
Then mask0x07E0 AND 0x3F0x0020 = 32

The signed decimal row reads 0xFC08 as −1016. That number is meaningless here. A packed word is a set of unsigned fields, and the top bit belongs to red, not to a sign. This is the most common reason to read the unsigned row rather than the signed one.

Unix mode bits: masks you already use

The st_mode value that stat() returns packs a file-type field and the permission bits into one integer. The Linux inode(7) man page lists S_IFMT 0170000 as the “bit mask for the file type bit field”, with S_IFDIR 0040000 and S_IFREG 0100000 as two of its values. It tests for a regular file with exactly the “test” move from above:

if ((sb.st_mode & S_IFMT) == S_IFREG) { /* Handle regular file */ }

Node exposes the same constants:

import fs from 'node:fs'
const { mode } = fs.statSync('/tmp')
mode.toString(8)                                         // '41777' on our Mac
;(mode & fs.constants.S_IFMT) === fs.constants.S_IFDIR   // true
;(mode & 0o7777).toString(8)                             // '1777'

The low twelve bits, 1777, are the sticky bit (S_ISVTX 01000) plus rwxrwxrwx. In our calculator, A = 0o41777 AND B = 0o170000 at 16 bits shows 0o40000 in the octal row. That is S_IFDIR. If octal permissions are the part you’re stuck on, the chmod calculator and chmod 755 vs 644 cover them bit by bit. The default permissions on a new file are the “clear” move again: 0o666 & ~0o022 is 0o644, which is the whole idea of umask.

JavaScript: where masks break

JavaScript numbers are doubles, but the bitwise operators don’t use doubles. The ECMAScript spec (§6.1.6.1.9 Number::leftShift) says: “Let leftNumber be ! ToInt32(x). Let rightNumber be ! ToUint32(y). Let shiftCount be ℝ(rightNumber) modulo 32.” The other operators coerce to 32 bits the same way. The two’s-complement post explains >> vs >>> and signedness in general. Here we only cover what this does to masks.

Flag 31 makes the value negative, and an equality test against it fails.

const TOP = 0x80000000;     // 2147483648, a positive double
const f = TOP | 1;          // -2147483647
(f & TOP) === TOP           // false: f & TOP is -2147483648
(f & TOP) !== 0             // true
((f & TOP) >>> 0) === TOP   // true: >>> 0 reads it back as unsigned

If you need all 32 flags, test with !== 0, or normalize with >>> 0 before you compare.

Shift counts wrap at 32, and higher bits are silently dropped.

1 << 31     // -2147483648
1 << 32     // 1   (32 mod 32 = 0)
1 << 33     // 2
2 ** 40 | 1 // 1   (bit 40 is discarded by ToInt32)

MDN puts it directly: “If the left operand is a number with more than 32 bits, it will get the most significant bits discarded.” That same truncation breaks the power-of-two check from earlier:

isPow2(2 ** 32)      // true, correct but by luck: 2**32 | 0 is 0
isPow2(3 * 2 ** 32)  // true, wrong: 3 * 2**32 also truncates to 0

The x !== 0 guard runs on the double, which isn’t zero. The AND then runs on the truncated value, which is zero. Any number-typed bit helper is only correct for inputs that fit in 32 bits.

C has the same problem in a worse form. cppreference says that for shifts, “the behavior is undefined if rhs is negative or is greater or equal the number of bits in the promoted lhs”. In C, 1 << 32 on a 32-bit int has no defined value at all.

BigInt for 64-bit masks

Above 32 bits, use BigInt. Every operator except >>> works on it with no truncation:

const BIT40 = 1n << 40n;         // 1099511627776n
(BIT40 | 1n) & BIT40             // 1099511627776n, the test still works
1n << 3                          // TypeError: Cannot mix BigInt and other types, use explicit conversions
9n >>> 2n                        // TypeError: BigInts have no unsigned right shift, use >> instead

Both error messages above are Node 22’s exact wording. MDN explains why >>> is missing: BigInts “have an infinite number of leading sign bits, so there’s no ‘leftmost bit’ to fill with zeroes.”

Those infinite leading bits also mean ~ gives a negative number: ~0n is -1n, not 0xFFFF_FFFF_FFFF_FFFFn. For clearing bits that’s fine, because x & ~0xFFn still clears the low byte of a 64-bit value. When you need an exact fixed-width result, for example after a shift that may overflow or when writing to a BigUint64Array, wrap it in BigInt.asUintN, which “truncates a BigInt value to the given number of least significant bits and returns that value as an unsigned integer”. BigInt.asIntN does the same but returns a signed integer:

BigInt.asUintN(64, ~0n)    // 18446744073709551615n
BigInt.asUintN(8, -1n)     // 255n
BigInt.asIntN(8, 255n)     // -1n
BigInt.asUintN(64, 1n << 64n)  // 0n, the shift overflowed out of 64 bits

Our calculator is built on these two functions. Every operation runs on BigInt, masks each operand with asUintN(width, …) (shift amounts are the exception, below), and fills the signed decimal row with asIntN. That’s why its 64-bit results are exact.

What the calculator does at each width

Because it isn’t limited by JavaScript’s 32-bit operators, the tool behaves like a fixed-width register at each of 8, 16, 32 and 64 bits:

Width1 SHL w − 1 (hex, unsigned / signed)1 SHL wNOT 0 (unsigned / signed)
80x80, 128 / −1280x00255 / −1
160x8000, 32768 / −327680x000065535 / −1
320x80000000, 2147483648 / −21474836480x000000004294967295 / −1
640x8000000000000000, 9223372036854775808 / −92233720368547758080x000000000000000018446744073709551615 / −1

The 32-bit row is where it differs from JavaScript. Native 1 << 32 gives 1. Our tool gives 0, and shows a note that shifting by the full width moves every bit out. A few other edge cases:

  • Shift amounts larger than the width are not wrapped. SHL or SHR by 256 at 8 bits gives 0. An arithmetic right shift (SAR) by the width or more fills the result with the sign bit, so 0x80 SAR 200 at 8 bits is 0xFF. The shift-amount bit grid can only draw width bits, so for an amount like 256 at 8 bits the tool labels the grid as showing the low bits and disables editing it, while the shift itself still uses 256.
  • Rotates take the amount modulo the width. At 8 bits, 0x81 ROL 9 gives 0x03, the same as ROL 1.
  • A negative shift amount is read as its unsigned bit pattern. At 8 bits, -1 means 255. ROL by -1 is therefore ROL by 7, which equals ROR by 1: 0x81 gives 0xC0 either way. SHL by -1 gives 0.
  • Operands wider than the width are cut to their low bits. 300 at 8 bits becomes 0x2C (44), the same truncation a C cast to uint8_t performs. Whenever a value doesn’t fit, the tool shows a note. That includes negative values outside the signed range, such as -300 at 8 bits, which wraps to 0xD4 (212). -128 fits in 8 bits and gets no note, but -129 does.

Input parsing accepts 0x, 0b and 0o prefixes in Auto mode, plus underscores or spaces as digit separators, so 0x1_FF reads as 511. A leading - negates the value, and +5 or 1e3 are rejected as invalid. The calculator has no add or subtract, so for the x - 1 tricks you work out the second operand yourself. Everything runs in your browser; nothing you type is sent anywhere.

Practical checklist

  1. Write flags as 1 << n and keep them in one place, so no two flags can share a bit.
  2. Clear with & ~mask and test with !== 0. In JavaScript, === mask fails for bit 31 unless you normalize with >>> 0.
  3. Switch to BigInt at bit 31 or above, and use asUintN(64, …) whenever you need the result to stay exactly 64 bits.
  4. Treat packed values as unsigned. A negative reading of a packed word is a display problem, not a value.
  5. When a mask misbehaves, look at the bits. Paste both values into the bitwise calculator at the width your code actually uses. To convert a single value between bases, use the number base converter. If you work with netmasks, the subnet calculator is the same AND-with-a-mask idea applied to IP addresses. For XOR across whole strings, why toy ciphers still matter and the XOR cipher pick up the next step.