Signed and Unsigned Integers: How Computers Store Numbers
When you declare a variable to store a number in your code, you're making an important choice: should this number be able to represent negative values? This fundamental question leads us to signed and unsigned integers—two different ways computers store whole numbers in memory.
Understanding the difference between signed and unsigned integers helps you write more efficient code, avoid subtle bugs, and make better decisions about data types. Let's explore how computers represent positive and negative numbers, and why this matters in real-world programming.
What Are Signed and Unsigned Integers?
At their core, integers are whole numbers without decimal points. But computers need to decide how to interpret the bits that make up these numbers.
Unsigned Integers: Positive Numbers Only
An unsigned integer can only represent positive numbers (and zero). All the bits are used to store the magnitude of the number.
Think of it this way: Imagine you have a counter that only counts up from zero. It can show 0, 1, 2, 3, and so on, but it can never show negative numbers. That's an unsigned integer—it's optimized for counting things that can't be negative, like the number of users on your website or the number of items in a shopping cart.
Signed Integers: Positive and Negative Numbers
A signed integer can represent both positive and negative numbers. One bit is reserved to indicate whether the number is positive or negative, which means you have fewer bits available for the actual magnitude.
Real-world analogy: Think of your bank account balance. It can be positive (you have money) or negative (you're overdrawn). A signed integer is like a bank balance—it needs to track both directions.
Why This Matters
The choice between signed and unsigned affects:
- Range of values: What's the largest and smallest number you can store?
- Memory efficiency: Are you wasting a bit on the sign when you don't need it?
- Overflow behavior: What happens when you exceed the limits?
- Operations: How do comparisons and arithmetic work?
How Computers Store Signed Integers
Computers use a clever system called two's complement to represent negative numbers. Let's understand why this system exists and how it works.
The Problem with Simple Sign Bit
At first glance, it seems reasonable to use one bit to indicate whether a number is positive or negative, and the rest for the magnitude. This is called the sign-magnitude approach. But while it looks simple, it quickly becomes a headache for both programmers and hardware designers.
Imagine this scenario:
You’re building a calculator. You decide that the first bit will be the sign (0 for positive, 1 for negative), and the rest will be the value. So +5 is 0 0000101
, and -5 is 1 0000101
.
But here’s where things get messy:
- Two zeros: You end up with both +0 (
0 0000000
) and -0 (1 0000000
). Which one is “real” zero? This confuses both humans and computers, and wastes a perfectly good bit pattern. - Arithmetic headaches: Want to add or subtract numbers? You need special logic to handle the sign bit, which slows down calculations and makes hardware more complex.
- Comparisons get tricky: Is -5 less than +5? Sure, but you have to check the sign bit and then compare the magnitude. It’s not as straightforward as you’d hope.
- Overflow confusion: When you go past the maximum or minimum value, the results aren’t predictable. This can lead to subtle bugs that are hard to track down.
In short, sign-magnitude looks easy but creates a lot of unnecessary problems. What we really want is a system where arithmetic and comparisons work smoothly, zero is always zero, and hardware can be simple and fast.
Two's Complement: The Elegant Solution
Two’s complement is the clever solution that computers use to represent signed integers. It’s not just a technical fix—it’s a design that makes life easier for both hardware engineers and software developers.
How does it work? For an 8-bit signed integer:
Positive numbers: Same as unsigned (0 to 127)
0 = 00000000
1 = 00000001
127 = 01111111
Negative numbers: Use two's complement (-128 to -1)
-1 = 11111111
-2 = 11111110
-128 = 10000000
Why do we love two’s complement?
- Zero is always zero: There’s only one way to write zero (
00000000
). No more confusion. - Arithmetic is a breeze: You can add, subtract, and multiply positive and negative numbers using the same circuits. No special cases, no extra logic.
- Overflow is predictable: If you add 1 to the biggest positive number, it wraps around to the smallest negative. This wraparound is consistent and often useful in programming.
- Sign is obvious: The leftmost bit tells you if the number is positive (0) or negative (1). No need to check a separate sign bit.
- Bitwise operations just work: AND, OR, NOT, and other bitwise tricks behave the same for both positive and negative numbers.
- Subtraction is just addition: To subtract, you add the two’s complement of a number. This means subtraction is as fast and simple as addition.
In plain English: Two’s complement is the reason computers can do fast, reliable math with both positive and negative numbers. It keeps hardware simple, avoids weird edge cases, and makes programming less error-prone. That’s why it’s the universal choice for signed integers in modern computing. 6. No need for special subtraction logic: Subtracting a number is the same as adding its two's complement, so subtraction is just addition with a negative operand.
The key insight:
- If the leftmost bit (most significant bit) is 0, the number is positive
- If the leftmost bit is 1, the number is negative
Summary: Two's complement is preferred because it makes hardware design simpler, arithmetic operations faster and more reliable, and eliminates confusing edge cases like negative zero. This is why virtually all modern computers use two's complement for signed integers.
Converting to Two's Complement
To convert a positive number to its negative equivalent:
Step-by-step process for -5:
Step 1: Start with positive number
5 = 00000101
Step 2: Flip all bits (one's complement)
11111010
Step 3: Add 1
11111010
+ 1
-----------
11111011
Result: -5 = 11111011
Why this works:
When you add a number and its two's complement, you always get zero:
00000101 (+5)
+ 11111011 (-5)
-----------
00000000 (0, with carry bit discarded)
This makes addition and subtraction use the same hardware circuitry—a huge advantage for computer design!
Visual Representation
Let's see how 8-bit signed integers map to values:
Binary Value → Decimal Value
01111111 (127) ─────► Positive range
01111110 (126) ↑
... │
00000010 (2) │
00000001 (1) │
00000000 (0) ─────── Zero
11111111 (-1) │
11111110 (-2) │
... │
10000001 (-127) ↓
10000000 (-128) ─────► Negative range
The number line wraps around:
- Largest positive: 01111111 = 127
- Zero: 00000000 = 0
- Smallest negative: 10000000 = -128
Notice that the negative range is one larger than the positive range. With 8 bits, you get:
- Positive: 0 to 127 (128 values)
- Negative: -128 to -1 (128 values)
- Total: 256 unique values (2^8)
Understanding Value Ranges
How is a value range calculated?
-
For an unsigned integer (only positive numbers):
- All bits are used for magnitude.
- The range is from 0 to 2^n - 1, where n is the number of bits.
- Example: An 8-bit unsigned integer can store values from 0 to 2^8 - 1 = 255.
-
For a signed integer (positive and negative numbers, using two's complement):
- One bit is used for the sign, so the range is split between negative and positive values.
- The range is from -2^(n-1) to 2^(n-1) - 1.
- Example: An 8-bit signed integer can store values from -2^7 = -128 to 2^7 - 1 = 127.
So, the positive side is 2^(n-1) - 1 because zero takes up one slot in the positive range.
This calculation helps you quickly determine the limits of any integer type, so you can choose the right one for your data and avoid overflow errors.
Let's explore common integer sizes:
8-Bit Integers
// Unsigned 8-bit integer (uint8)
Range: 0 to 255
Total values: 256 (2^8)
Example values:
00000000 = 0
11111111 = 255
// Signed 8-bit integer (int8)
Range: -128 to 127
Total values: 256 (2^8)
Example values:
10000000 = -128 (most negative)
00000000 = 0
01111111 = 127 (most positive)
11111111 = -1
16-Bit Integers
// Unsigned 16-bit integer (uint16)
Range: 0 to 65,535
Total values: 65,536 (2^16)
// Signed 16-bit integer (int16)
Range: -32,768 to 32,767
Total values: 65,536 (2^16)
32-Bit Integers
// Unsigned 32-bit integer (uint32)
Range: 0 to 4,294,967,295
Total values: ~4.3 billion (2^32)
// Signed 32-bit integer (int32)
Range: -2,147,483,648 to 2,147,483,647
Total values: ~4.3 billion (2^32)
64-Bit Integers
// Unsigned 64-bit integer (uint64)
Range: 0 to 18,446,744,073,709,551,615
Total values: ~18.4 quintillion (2^64)
// Signed 64-bit integer (int64)
Range: -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807
Total values: ~18.4 quintillion (2^64)
Quick Reference Table
Type | Bits | Signed Range | Unsigned Range |
---|---|---|---|
int8 / uint8 | 8 | -128 to 127 | 0 to 255 |
int16 / uint16 | 16 | -32,768 to 32,767 | 0 to 65,535 |
int32 / uint32 | 32 | -2.1B to 2.1B | 0 to 4.3B |
int64 / uint64 | 64 | -9.2Q to 9.2Q | 0 to 18.4Q |
Key observation:
- Signed integers can represent negative values but have a smaller positive range
- Unsigned integers double the positive range by giving up negative values
- Both types store the same total number of unique values
Working with Signed and Unsigned Integers in TypeScript
TypeScript and JavaScript don't have built-in unsigned integer types at the language level, but you can work with them using TypedArrays when dealing with binary data.
Using TypedArrays for Specific Integer Types
// Unsigned 8-bit integers (0 to 255)
const uint8Array = new Uint8Array([0, 127, 255]);
console.log(uint8Array[0]); // 0
console.log(uint8Array[1]); // 127
console.log(uint8Array[2]); // 255
// Signed 8-bit integers (-128 to 127)
const int8Array = new Int8Array([0, 127, -128]);
console.log(int8Array[0]); // 0
console.log(int8Array[1]); // 127
console.log(int8Array[2]); // -128
// What happens with overflow?
const uint8 = new Uint8Array(1);
uint8[0] = 256; // Exceeds max (255)
console.log(uint8[0]); // 0 (wraps around)
uint8[0] = 257;
console.log(uint8[0]); // 1 (wraps around)
const int8 = new Int8Array(1);
int8[0] = 128; // Exceeds max (127)
console.log(int8[0]); // -128 (wraps to minimum)
int8[0] = -129; // Below min (-128)
console.log(int8[0]); // 127 (wraps to maximum)
Working with 16-Bit Integers
// Unsigned 16-bit integers (0 to 65,535)
const uint16Array = new Uint16Array([0, 32767, 65535]);
console.log(uint16Array[0]); // 0
console.log(uint16Array[1]); // 32,767
console.log(uint16Array[2]); // 65,535
// Signed 16-bit integers (-32,768 to 32,767)
const int16Array = new Int16Array([-32768, 0, 32767]);
console.log(int16Array[0]); // -32,768
console.log(int16Array[1]); // 0
console.log(int16Array[2]); // 32,767
// Practical example: Audio sample data
// Audio samples are often 16-bit signed integers
function processAudioSample(sample: Int16Array): void {
for (let i = 0; i < sample.length; i++) {
// Each sample is a value from -32,768 to 32,767
// representing the amplitude of the sound wave
const amplitude = sample[i];
console.log(`Sample ${i}: ${amplitude}`);
}
}
Working with 32-Bit Integers
// Unsigned 32-bit integers (0 to 4,294,967,295)
const uint32Array = new Uint32Array([0, 2147483647, 4294967295]);
console.log(uint32Array[0]); // 0
console.log(uint32Array[1]); // 2,147,483,647
console.log(uint32Array[2]); // 4,294,967,295
// Signed 32-bit integers (-2,147,483,648 to 2,147,483,647)
const int32Array = new Int32Array([-2147483648, 0, 2147483647]);
console.log(int32Array[0]); // -2,147,483,648
console.log(int32Array[1]); // 0
console.log(int32Array[2]); // 2,147,483,647
// Practical example: Pixel color data
// RGBA pixels are often stored as 32-bit unsigned integers
interface RGBAColor {
r: number; // 0-255
g: number; // 0-255
b: number; // 0-255
a: number; // 0-255
}
function colorToUint32(color: RGBAColor): number {
// Pack RGBA into a single 32-bit unsigned integer
const uint32 = new Uint32Array(1);
uint32[0] =
(color.r << 24) | // Red in highest 8 bits
(color.g << 16) | // Green in next 8 bits
(color.b << 8) | // Blue in next 8 bits
color.a; // Alpha in lowest 8 bits
return uint32[0];
}
function uint32ToColor(packed: number): RGBAColor {
return {
r: (packed >>> 24) & 0xff, // Extract red
g: (packed >>> 16) & 0xff, // Extract green
b: (packed >>> 8) & 0xff, // Extract blue
a: packed & 0xff, // Extract alpha
};
}
// Usage
const red: RGBAColor = { r: 255, g: 0, b: 0, a: 255 };
const packed = colorToUint32(red);
console.log(`Packed color: ${packed}`);
const unpacked = uint32ToColor(packed);
console.log(`Unpacked:`, unpacked); // { r: 255, g: 0, b: 0, a: 255 }
Reading Binary Data with Correct Integer Types
import * as fs from "fs";
// Reading binary file with specific integer types
async function readBinaryFile(filePath: string): Promise<void> {
const buffer = await fs.promises.readFile(filePath);
// Create a DataView for flexible reading
const view = new DataView(buffer.buffer);
// Read unsigned 8-bit integer at offset 0
const uint8Value = view.getUint8(0);
console.log(`Uint8 at offset 0: ${uint8Value}`);
// Read signed 8-bit integer at offset 1
const int8Value = view.getInt8(1);
console.log(`Int8 at offset 1: ${int8Value}`);
// Read unsigned 16-bit integer at offset 2 (big-endian)
const uint16Value = view.getUint16(2, false); // false = big-endian
console.log(`Uint16 at offset 2: ${uint16Value}`);
// Read signed 32-bit integer at offset 4 (little-endian)
const int32Value = view.getInt32(4, true); // true = little-endian
console.log(`Int32 at offset 4: ${int32Value}`);
}
// Writing binary data with specific integer types
async function writeBinaryFile(filePath: string): Promise<void> {
// Create a buffer of 10 bytes
const buffer = new ArrayBuffer(10);
const view = new DataView(buffer);
// Write unsigned 8-bit integer (0-255)
view.setUint8(0, 200);
// Write signed 8-bit integer (-128 to 127)
view.setInt8(1, -50);
// Write unsigned 16-bit integer (big-endian)
view.setUint16(2, 50000, false);
// Write signed 32-bit integer (little-endian)
view.setInt32(4, -1000000, true);
// Convert to Node.js Buffer and save
const nodeBuffer = Buffer.from(buffer);
await fs.promises.writeFile(filePath, nodeBuffer);
console.log("Binary file written successfully");
}
Integer Overflow: What Happens When You Exceed Limits
One of the most important concepts to understand is overflow—what happens when you try to store a value that's too large (or too small) for the integer type.
Overflow Behavior in Unsigned Integers
Unsigned integers wrap around to zero when they exceed their maximum value.
// 8-bit unsigned integer example
const uint8 = new Uint8Array(1);
// Maximum value is 255
uint8[0] = 255;
console.log(`Value: ${uint8[0]}`); // 255
// Add 1: wraps to 0
uint8[0] = 256;
console.log(`Value: ${uint8[0]}`); // 0
// Add more: continues wrapping
uint8[0] = 257;
console.log(`Value: ${uint8[0]}`); // 1
uint8[0] = 300;
console.log(`Value: ${uint8[0]}`); // 44 (300 - 256 = 44)
// Visual representation of wrapping
/*
255 → 256 → 257 → 258
↑ ↓
└─ 0 ← 1 ← 2 ← 3
*/
Why wrapping happens:
When you add 1 to 255 in binary:
11111111 (255)
+ 1
-----------
1 00000000 (256 in 9 bits, but we only have 8 bits)
The leftmost bit is discarded, leaving:
00000000 (0)
Overflow Behavior in Signed Integers
Signed integers wrap from maximum positive to minimum negative (and vice versa).
// 8-bit signed integer example
const int8 = new Int8Array(1);
// Maximum value is 127
int8[0] = 127;
console.log(`Value: ${int8[0]}`); // 127
// Add 1: wraps to -128
int8[0] = 128;
console.log(`Value: ${int8[0]}`); // -128
// Add more: continues from minimum
int8[0] = 129;
console.log(`Value: ${int8[0]}`); // -127
// Underflow: below minimum wraps to maximum
int8[0] = -129;
console.log(`Value: ${int8[0]}`); // 127
// Visual representation
/*
126 → 127 → 128 → 129
↑ ↓
└─ -127 ←─ -128
*/
Why this happens:
In two's complement, adding 1 to the maximum positive value:
01111111 (127)
+ 1
-----------
10000000 (-128 in two's complement)
The bit pattern 10000000 represents -128 in signed interpretation!
Real-World Overflow Example
// Dangerous: Counter overflow
class UserCounter {
private count: Uint32Array; // 0 to 4,294,967,295
constructor() {
this.count = new Uint32Array(1);
}
increment(): void {
this.count[0]++;
// What if this reaches 4,294,967,295 and wraps to 0?
// You'd lose your count!
}
getCount(): number {
return this.count[0];
}
}
// Better: Check for overflow
class SafeUserCounter {
private count: number; // JavaScript's number type (64-bit float)
constructor() {
this.count = 0;
}
increment(): void {
if (this.count >= Number.MAX_SAFE_INTEGER) {
throw new Error("Counter overflow! Consider using BigInt.");
}
this.count++;
}
getCount(): number {
return this.count;
}
}
// Even better: Use BigInt for unbounded integers
class UnboundedCounter {
private count: bigint;
constructor() {
this.count = 0n;
}
increment(): void {
this.count++; // No overflow possible!
}
getCount(): bigint {
return this.count;
}
}
Detecting and Preventing Overflow
// Function to safely add unsigned integers
function safeAddUint8(a: number, b: number): number | null {
const result = a + b;
// Check if result exceeds uint8 range
if (result > 255) {
console.error(`Overflow: ${a} + ${b} = ${result} exceeds uint8 max (255)`);
return null;
}
return result;
}
// Function to safely add signed integers
function safeAddInt8(a: number, b: number): number | null {
const result = a + b;
// Check if result exceeds int8 range
if (result > 127 || result < -128) {
console.error(
`Overflow: ${a} + ${b} = ${result} exceeds int8 range (-128 to 127)`
);
return null;
}
return result;
}
// Usage
console.log(safeAddUint8(200, 50)); // null (overflow)
console.log(safeAddUint8(200, 55)); // 255 (valid)
console.log(safeAddInt8(100, 28)); // null (overflow)
console.log(safeAddInt8(100, 27)); // 127 (valid)
console.log(safeAddInt8(-100, -29)); // null (underflow)
When to Use Signed vs Unsigned Integers
Choosing between signed and unsigned integers depends on your use case. Here's how to decide:
Use Unsigned Integers When:
1. Values are never negative
// Counting items (can't have negative items)
const itemCount = new Uint32Array(1);
itemCount[0] = 1000; // Number of products
// Array indices (can't be negative)
const indices = new Uint16Array([0, 1, 2, 3, 4]);
// Color values (0-255 range)
const red = new Uint8Array([255, 200, 100]);
2. You need maximum positive range
// User IDs on a large platform
// Unsigned 32-bit: 0 to 4,294,967,295 (4.3 billion users)
// Signed 32-bit: -2.1B to 2.1B (only 2.1 billion positive IDs)
const userId = new Uint32Array([3_500_000_000]);
// File sizes (can't be negative)
const fileSize = new Uint32Array([2_500_000_000]); // 2.5 GB
3. Working with binary protocols
// Network packets often use unsigned integers
interface PacketHeader {
length: number; // Uint16: packet length in bytes
sequenceNum: number; // Uint32: sequence number
checksum: number; // Uint16: error checking
}
function createPacketHeader(data: Uint8Array): PacketHeader {
return {
length: data.length,
sequenceNum: Math.floor(Math.random() * 4294967295),
checksum: calculateChecksum(data),
};
}
function calculateChecksum(data: Uint8Array): number {
let sum = 0;
for (let i = 0; i < data.length; i++) {
sum += data[i];
}
// Return 16-bit unsigned checksum
return sum & 0xffff;
}
Use Signed Integers When:
1. Values can be positive or negative
// Temperature readings (can be below zero)
const temperatures = new Int8Array([-10, -5, 0, 5, 15, 25]);
// Account balance (can be negative)
const balance = new Int32Array([1000, -500, 2500, -100]);
// Coordinate positions (can be negative)
interface Point {
x: number; // Can be negative
y: number; // Can be negative
}
const points = new Int16Array([-100, 50, 200, -75, 0, 0]);
// Points: (-100, 50), (200, -75), (0, 0)
2. You need to represent differences or deltas
// Change in stock price (can go up or down)
const priceChange = new Int32Array([-150, 200, -50, 100]);
// -150 cents = $1.50 decrease
// 200 cents = $2.00 increase
// Movement in game (can move in any direction)
interface Movement {
deltaX: number; // Signed: left (-) or right (+)
deltaY: number; // Signed: down (-) or up (+)
}
function movePlayer(current: Point, movement: Movement): Point {
return {
x: current.x + movement.deltaX,
y: current.y + movement.deltaY,
};
}
3. Mathematical operations involving subtraction
// Time differences (can be negative if event B happened before A)
interface Event {
timestamp: number; // Milliseconds since epoch
}
function timeDifference(eventA: Event, eventB: Event): number {
// Can be negative if B happened before A
return eventA.timestamp - eventB.timestamp;
}
// Elevation changes (can go down)
const elevations = new Int16Array([100, 150, 120, 80, 90]);
// Calculate changes between points
function getElevationChanges(elevations: Int16Array): Int16Array {
const changes = new Int16Array(elevations.length - 1);
for (let i = 0; i < changes.length; i++) {
changes[i] = elevations[i + 1] - elevations[i];
// Can be positive (going up) or negative (going down)
}
return changes;
}
Quick Decision Guide
// Decision tree for choosing integer type
function chooseIntegerType(scenario: string): string {
interface Scenario {
question: string;
ifYes: string;
ifNo: string;
}
const decisionTree: { [key: string]: Scenario } = {
start: {
question: "Can the value be negative?",
ifYes: "useSigned",
ifNo: "checkRange",
},
checkRange: {
question: "Do you need values > 2 billion?",
ifYes: "useUnsigned32Or64",
ifNo: "useUnsigned16Or8",
},
useSigned: {
question: "Do you need values beyond ±2 billion?",
ifYes: "signed64",
ifNo: "signed32",
},
useUnsigned32Or64: {
question: "Do you need values > 4 billion?",
ifYes: "unsigned64",
ifNo: "unsigned32",
},
useUnsigned16Or8: {
question: "Do you need values > 255?",
ifYes: "unsigned16",
ifNo: "unsigned8",
},
};
// Return recommendation based on scenario
// (Implementation simplified for demonstration)
return "Use the decision tree to determine the right type!";
}
Common Pitfalls and How to Avoid Them
Pitfall 1: Comparing Signed and Unsigned Values
// ❌ Wrong: Mixing signed and unsigned can cause issues
function compareValues(): void {
const signed = new Int8Array([-1]);
const unsigned = new Uint8Array([255]);
// These represent the same bit pattern: 11111111
// But they have different meanings!
console.log(`Signed: ${signed[0]}`); // -1
console.log(`Unsigned: ${unsigned[0]}`); // 255
// Comparing them directly can be confusing
if (signed[0] === unsigned[0]) {
// This is false! -1 !== 255
console.log("Equal");
} else {
console.log("Not equal");
}
}
// ✅ Correct: Be explicit about conversions
function compareValuesSafely(): void {
const signedValue = -1;
const unsignedValue = 255;
// If you need to compare, decide on a common interpretation
// Option 1: Compare as signed
const unsignedAsSigned =
unsignedValue > 127 ? unsignedValue - 256 : unsignedValue;
console.log(`Comparing as signed: ${signedValue} vs ${unsignedAsSigned}`);
// Option 2: Compare bit patterns
const signedBits = signedValue & 0xff; // Get lowest 8 bits
console.log(`Bit patterns equal: ${signedBits === unsignedValue}`);
}
Pitfall 2: Not Checking for Overflow
// ❌ Wrong: Assuming addition won't overflow
function addUserPoints(currentPoints: Uint16Array, pointsToAdd: number): void {
// This can overflow silently!
currentPoints[0] += pointsToAdd;
// If current = 65,000 and adding 1,000
// Result wraps to 464 (66,000 - 65,536)
}
// ✅ Correct: Check for overflow before operation
function addUserPointsSafely(
currentPoints: Uint16Array,
pointsToAdd: number
): boolean {
const maxValue = 65535; // Uint16 max
// Check if addition would overflow
if (currentPoints[0] + pointsToAdd > maxValue) {
console.error(`Overflow: Cannot add ${pointsToAdd} to ${currentPoints[0]}`);
// Set to maximum value or handle gracefully
currentPoints[0] = maxValue;
return false;
}
currentPoints[0] += pointsToAdd;
return true;
}
// Even better: Use a larger type or BigInt
function addUserPointsWithBigInt(
currentPoints: bigint,
pointsToAdd: bigint
): bigint {
// No overflow possible
return currentPoints + pointsToAdd;
}
Pitfall 3: Losing Negative Sign When Converting
// ❌ Wrong: Converting signed to unsigned loses sign information
function convertSignedToUnsigned(): void {
const signed = new Int8Array([-50]);
const unsigned = new Uint8Array(signed.buffer);
console.log(`Signed: ${signed[0]}`); // -50
console.log(`Unsigned: ${unsigned[0]}`); // 206 (wrong!)
// The bits are the same, but interpretation changed
// -50 in two's complement = 11001110 = 206 unsigned
}
// ✅ Correct: Handle negative values appropriately
function convertSafely(signedValue: number): number {
if (signedValue < 0) {
console.warn(`Cannot represent negative value ${signedValue} as unsigned`);
return 0; // Or throw error, or use absolute value
}
return signedValue;
}
// Alternative: Map negative values to a valid range
function mapToUnsignedRange(
signedValue: number,
min: number,
max: number
): number {
// Map signed range [-128, 127] to unsigned [0, 255]
// Formula: unsigned = signed - min
return signedValue - min;
}
// Example usage
console.log(mapToUnsignedRange(-128, -128, 127)); // 0
console.log(mapToUnsignedRange(0, -128, 127)); // 128
console.log(mapToUnsignedRange(127, -128, 127)); // 255
Pitfall 4: Incorrect Bitwise Operations
// ❌ Wrong: Using bitwise operators without understanding sign extension
function extractBytesWrong(value: number): void {
// JavaScript numbers are 64-bit floats, bitwise ops work on 32-bit signed
const byte1 = value >> 24; // Sign extends!
const byte2 = value >> 16;
console.log(`Byte 1: ${byte1}`); // Might be negative!
}
// ✅ Correct: Use unsigned right shift (>>>) for unsigned values
function extractBytesCorrectly(value: number): number[] {
// Use >>> for unsigned right shift (no sign extension)
const byte1 = (value >>> 24) & 0xff; // Extract bits 24-31
const byte2 = (value >>> 16) & 0xff; // Extract bits 16-23
const byte3 = (value >>> 8) & 0xff; // Extract bits 8-15
const byte4 = value & 0xff; // Extract bits 0-7
return [byte1, byte2, byte3, byte4];
}
// Example
const packedValue = 0xdeadbeef; // 3,735,928,559
const bytes = extractBytesCorrectly(packedValue);
console.log(bytes); // [222, 173, 190, 239]
console.log(bytes.map((b) => `0x${b.toString(16).toUpperCase()}`));
// ['0xDE', '0xAD', '0xBE', '0xEF']
Pitfall 5: Assuming JavaScript Number Behavior
// ❌ Wrong: JavaScript's number type doesn't match TypedArray behavior
function javascriptNumberConfusion(): void {
let jsNumber = 255;
jsNumber++; // 256 - works fine in JavaScript
// But in TypedArray:
const uint8 = new Uint8Array([255]);
uint8[0]++; // Wraps to 0!
console.log(`JS Number: ${jsNumber}`); // 256
console.log(`Uint8Array: ${uint8[0]}`); // 0
}
// ✅ Correct: Understand the difference
function handleTypedArraysCorrectly(): void {
// JavaScript numbers are 64-bit floats
// Can represent integers up to Number.MAX_SAFE_INTEGER (2^53 - 1)
const jsNumber = 9007199254740991; // MAX_SAFE_INTEGER
// TypedArrays have fixed sizes and wrap on overflow
const uint32 = new Uint32Array([4294967295]); // Max uint32
console.log(`JS can go higher: ${jsNumber + 1}`);
console.log(`Uint32 wraps: ${uint32[0] + 1}`); // Would wrap to 0 if stored
// If you need to store in TypedArray, check first:
if (jsNumber > 4294967295) {
console.log("Value too large for Uint32Array, consider BigInt64Array");
}
}
Real-World Applications
Understanding signed and unsigned integers is crucial in many real-world scenarios. Let's explore practical applications:
Application 1: Image Processing
// RGB color values are unsigned 8-bit integers (0-255)
interface RGBPixel {
r: number; // 0-255
g: number; // 0-255
b: number; // 0-255
}
class ImageProcessor {
private width: number;
private height: number;
private pixels: Uint8ClampedArray; // Automatically clamps to 0-255
constructor(width: number, height: number) {
this.width = width;
this.height = height;
// Uint8ClampedArray: like Uint8Array but clamps instead of wrapping
this.pixels = new Uint8ClampedArray(width * height * 4); // RGBA
}
setPixel(x: number, y: number, color: RGBPixel): void {
const index = (y * this.width + x) * 4;
// Uint8ClampedArray ensures values stay in 0-255 range
this.pixels[index] = color.r; // Red
this.pixels[index + 1] = color.g; // Green
this.pixels[index + 2] = color.b; // Blue
this.pixels[index + 3] = 255; // Alpha (fully opaque)
}
getPixel(x: number, y: number): RGBPixel {
const index = (y * this.width + x) * 4;
return {
r: this.pixels[index],
g: this.pixels[index + 1],
b: this.pixels[index + 2],
};
}
// Brighten image (demonstrate clamping vs wrapping)
brighten(amount: number): void {
for (let i = 0; i < this.pixels.length; i += 4) {
// Uint8ClampedArray clamps to 255, doesn't wrap
this.pixels[i] += amount; // Red
this.pixels[i + 1] += amount; // Green
this.pixels[i + 2] += amount; // Blue
// If pixel was 200 and amount is 100, result is 255 (not 44)
}
}
// Invert colors (demonstrates unsigned arithmetic)
invert(): void {
for (let i = 0; i < this.pixels.length; i += 4) {
this.pixels[i] = 255 - this.pixels[i]; // Invert red
this.pixels[i + 1] = 255 - this.pixels[i + 1]; // Invert green
this.pixels[i + 2] = 255 - this.pixels[i + 2]; // Invert blue
}
}
}
// Usage
const img = new ImageProcessor(800, 600);
img.setPixel(0, 0, { r: 255, g: 0, b: 0 }); // Red pixel
img.brighten(50); // Brighten the image
Application 2: Audio Processing
// Audio samples are typically signed 16-bit integers
class AudioProcessor {
private sampleRate: number;
private samples: Int16Array; // -32,768 to 32,767
constructor(sampleRate: number, durationSeconds: number) {
this.sampleRate = sampleRate;
this.samples = new Int16Array(sampleRate * durationSeconds);
}
// Generate a sine wave (demonstrates signed values)
generateSineWave(frequency: number, amplitude: number): void {
const maxAmplitude = 32767; // Max for int16
const normalizedAmplitude = Math.min(amplitude, 1.0) * maxAmplitude;
for (let i = 0; i < this.samples.length; i++) {
const t = i / this.sampleRate; // Time in seconds
const value = Math.sin(2 * Math.PI * frequency * t);
// Convert -1.0 to 1.0 range into -32,768 to 32,767 range
this.samples[i] = Math.round(value * normalizedAmplitude);
}
}
// Apply fade in (demonstrates working with signed values)
applyFadeIn(durationSeconds: number): void {
const fadeInSamples = Math.min(
durationSeconds * this.sampleRate,
this.samples.length
);
for (let i = 0; i < fadeInSamples; i++) {
// Fade from 0% to 100% volume
const fadeMultiplier = i / fadeInSamples;
this.samples[i] = Math.round(this.samples[i] * fadeMultiplier);
}
}
// Normalize audio (demonstrates signed math)
normalize(): void {
// Find maximum absolute value
let maxValue = 0;
for (let i = 0; i < this.samples.length; i++) {
const absValue = Math.abs(this.samples[i]);
if (absValue > maxValue) {
maxValue = absValue;
}
}
// Calculate scaling factor
const scaleFactor = 32767 / maxValue;
// Apply normalization
for (let i = 0; i < this.samples.length; i++) {
this.samples[i] = Math.round(this.samples[i] * scaleFactor);
}
}
getSamples(): Int16Array {
return this.samples;
}
}
// Usage
const audio = new AudioProcessor(44100, 2); // 44.1kHz, 2 seconds
audio.generateSineWave(440, 0.5); // A4 note at 50% volume
audio.applyFadeIn(0.5); // 0.5 second fade in
audio.normalize(); // Maximize volume without clipping
Application 3: Network Protocol Implementation
// Network protocols use specific integer types for fields
interface TCPHeader {
sourcePort: number; // Uint16: 0-65,535
destinationPort: number; // Uint16: 0-65,535
sequenceNumber: number; // Uint32: 0-4,294,967,295
acknowledgmentNumber: number; // Uint32
dataOffset: number; // Uint4: 0-15 (4 bits)
flags: number; // Uint8: 8 flag bits
windowSize: number; // Uint16: 0-65,535
checksum: number; // Uint16
}
class TCPPacket {
private header: TCPHeader;
private data: Uint8Array;
constructor(header: TCPHeader, data: Uint8Array) {
this.header = header;
this.data = data;
}
// Serialize header to binary format
serializeHeader(): Uint8Array {
// TCP header is 20 bytes minimum
const buffer = new ArrayBuffer(20);
const view = new DataView(buffer);
// Write unsigned 16-bit integers (big-endian for network byte order)
view.setUint16(0, this.header.sourcePort, false);
view.setUint16(2, this.header.destinationPort, false);
// Write unsigned 32-bit integers
view.setUint32(4, this.header.sequenceNumber, false);
view.setUint32(8, this.header.acknowledgmentNumber, false);
// Pack data offset and flags into 2 bytes
const dataOffsetAndFlags =
(this.header.dataOffset << 12) | this.header.flags;
view.setUint16(12, dataOffsetAndFlags, false);
// Write remaining fields
view.setUint16(14, this.header.windowSize, false);
view.setUint16(16, this.header.checksum, false);
return new Uint8Array(buffer);
}
// Deserialize header from binary format
static deserializeHeader(buffer: Uint8Array): TCPHeader {
const view = new DataView(buffer.buffer);
// Read unsigned 16-bit integers (big-endian)
const sourcePort = view.getUint16(0, false);
const destinationPort = view.getUint16(2, false);
// Read unsigned 32-bit integers
const sequenceNumber = view.getUint32(4, false);
const acknowledgmentNumber = view.getUint32(8, false);
// Unpack data offset and flags
const dataOffsetAndFlags = view.getUint16(12, false);
const dataOffset = (dataOffsetAndFlags >> 12) & 0x0f;
const flags = dataOffsetAndFlags & 0xff;
// Read remaining fields
const windowSize = view.getUint16(14, false);
const checksum = view.getUint16(16, false);
return {
sourcePort,
destinationPort,
sequenceNumber,
acknowledgmentNumber,
dataOffset,
flags,
windowSize,
checksum,
};
}
// Calculate checksum (demonstrates unsigned arithmetic)
calculateChecksum(): number {
let sum = 0;
// Add header fields
sum += this.header.sourcePort;
sum += this.header.destinationPort;
sum += (this.header.sequenceNumber >> 16) & 0xffff;
sum += this.header.sequenceNumber & 0xffff;
// ... continue for all fields
// Add data
for (let i = 0; i < this.data.length; i += 2) {
const word = (this.data[i] << 8) | (this.data[i + 1] || 0);
sum += word;
}
// Fold 32-bit sum to 16 bits
while (sum >> 16) {
sum = (sum & 0xffff) + (sum >> 16);
}
// One's complement
return ~sum & 0xffff;
}
}
// Usage
const packet = new TCPPacket(
{
sourcePort: 8080,
destinationPort: 443,
sequenceNumber: 1000,
acknowledgmentNumber: 5000,
dataOffset: 5,
flags: 0x18, // PSH + ACK
windowSize: 65535,
checksum: 0, // Calculate after
},
new Uint8Array([72, 101, 108, 108, 111]) // "Hello"
);
const headerBytes = packet.serializeHeader();
console.log("Serialized header:", headerBytes);
Application 4: Game Development
// Game coordinates and health use different integer types
interface Player {
id: number; // Uint32: unique player ID
x: number; // Int16: signed position (-32k to 32k)
y: number; // Int16: signed position
health: number; // Uint8: 0-255 HP
maxHealth: number; // Uint8: 0-255 max HP
score: number; // Uint32: unsigned score
team: number; // Uint8: 0-255 team ID
}
class GameState {
private players: Map<number, Player>;
constructor() {
this.players = new Map();
}
addPlayer(id: number, x: number, y: number): void {
// Ensure ID is valid unsigned 32-bit
const playerId = Math.abs(id) >>> 0; // Convert to uint32
this.players.set(playerId, {
id: playerId,
x: this.clampInt16(x), // Clamp to signed 16-bit range
y: this.clampInt16(y),
health: 100, // Uint8 range
maxHealth: 100,
score: 0,
team: 0,
});
}
// Move player (demonstrates signed coordinate math)
movePlayer(id: number, deltaX: number, deltaY: number): void {
const player = this.players.get(id);
if (!player) return;
// Add deltas (can be negative)
player.x = this.clampInt16(player.x + deltaX);
player.y = this.clampInt16(player.y + deltaY);
}
// Damage player (demonstrates unsigned subtraction with bounds)
damagePlayer(id: number, damage: number): void {
const player = this.players.get(id);
if (!player) return;
// Ensure damage is unsigned
const unsignedDamage = Math.max(0, damage);
// Subtract but don't go below 0
player.health = Math.max(0, player.health - unsignedDamage);
if (player.health === 0) {
console.log(`Player ${id} has been eliminated!`);
}
}
// Add score (demonstrates overflow handling)
addScore(id: number, points: number): void {
const player = this.players.get(id);
if (!player) return;
const maxUint32 = 4294967295;
// Check for overflow before adding
if (player.score + points > maxUint32) {
console.warn(`Score overflow for player ${id}, capping at max`);
player.score = maxUint32;
} else {
player.score += points;
}
}
// Serialize game state to binary (for network transmission)
serialize(): Uint8Array {
const buffer = new ArrayBuffer(this.players.size * 18); // 18 bytes per player
const view = new DataView(buffer);
let offset = 0;
this.players.forEach((player) => {
view.setUint32(offset, player.id, false); // 4 bytes
view.setInt16(offset + 4, player.x, false); // 2 bytes (signed!)
view.setInt16(offset + 6, player.y, false); // 2 bytes (signed!)
view.setUint8(offset + 8, player.health); // 1 byte
view.setUint8(offset + 9, player.maxHealth); // 1 byte
view.setUint32(offset + 10, player.score, false); // 4 bytes
view.setUint8(offset + 14, player.team); // 1 byte
// 3 bytes padding for alignment (optional)
offset += 18;
});
return new Uint8Array(buffer);
}
// Helper: Clamp to signed 16-bit range
private clampInt16(value: number): number {
return Math.max(-32768, Math.min(32767, Math.round(value)));
}
}
// Usage
const game = new GameState();
game.addPlayer(1, 0, 0);
game.movePlayer(1, 100, -50); // Move right 100, down 50 (signed!)
game.damagePlayer(1, 30); // Take 30 damage
game.addScore(1, 1000); // Add 1000 points
const serialized = game.serialize();
console.log("Game state size:", serialized.length, "bytes");
Performance Considerations
Understanding when to use signed vs unsigned integers can affect performance:
Memory Efficiency
// Example: Storing age data for 1 million users
// ❌ Less efficient: Using 32-bit integers for small values
class UserDatabaseInefficient {
private ages: Uint32Array; // 4 bytes per age
constructor(userCount: number) {
this.ages = new Uint32Array(userCount);
// Memory: 1,000,000 users × 4 bytes = 4 MB
}
}
// ✅ More efficient: Using 8-bit integers for small values
class UserDatabaseEfficient {
private ages: Uint8Array; // 1 byte per age (0-255)
constructor(userCount: number) {
this.ages = new Uint8Array(userCount);
// Memory: 1,000,000 users × 1 byte = 1 MB (75% savings!)
}
}
// Choose the right size based on your data range
interface OptimalIntegerSize {
maxValue: number;
recommendedType: string;
bytesPerValue: number;
}
function recommendIntegerType(
maxValue: number,
needsNegative: boolean
): OptimalIntegerSize {
if (needsNegative) {
if (maxValue <= 127) {
return { maxValue, recommendedType: "Int8Array", bytesPerValue: 1 };
} else if (maxValue <= 32767) {
return { maxValue, recommendedType: "Int16Array", bytesPerValue: 2 };
} else {
return { maxValue, recommendedType: "Int32Array", bytesPerValue: 4 };
}
} else {
if (maxValue <= 255) {
return { maxValue, recommendedType: "Uint8Array", bytesPerValue: 1 };
} else if (maxValue <= 65535) {
return { maxValue, recommendedType: "Uint16Array", bytesPerValue: 2 };
} else {
return { maxValue, recommendedType: "Uint32Array", bytesPerValue: 4 };
}
}
}
// Usage
console.log(recommendIntegerType(200, false));
// { maxValue: 200, recommendedType: 'Uint8Array', bytesPerValue: 1 }
console.log(recommendIntegerType(50000, false));
// { maxValue: 50000, recommendedType: 'Uint32Array', bytesPerValue: 4 }
Cache Performance
// Smaller integer types improve cache efficiency
// Example: Processing pixel data
function processImageEfficient(width: number, height: number): void {
// Use Uint8Array for RGB (each value 0-255)
const pixels = new Uint8Array(width * height * 3); // 1 byte per channel
// More pixels fit in CPU cache = faster processing
// With Uint8: 1 MB can hold ~333,000 pixels
// With Uint32: 1 MB can hold ~83,000 pixels
for (let i = 0; i < pixels.length; i += 3) {
pixels[i] = 255; // Red
pixels[i + 1] = 0; // Green
pixels[i + 2] = 0; // Blue
}
}
Summary: Key Takeaways
Understanding signed and unsigned integers is fundamental to working with binary data. Here's what you need to remember:
Core Concepts
Unsigned Integers:
- Only represent positive numbers and zero
- Use all bits for magnitude
- Range: 0 to 2^n - 1 (where n = number of bits)
- Best for: counts, indices, IDs, sizes, colors
Signed Integers:
- Represent both positive and negative numbers
- Use two's complement representation
- Range: -2^(n-1) to 2^(n-1) - 1
- Best for: temperatures, coordinates, deltas, balances
Two's Complement
- Modern way to represent negative numbers
- Makes addition/subtraction use same circuitry
- To negate: flip all bits and add 1
- Most significant bit indicates sign (0 = positive, 1 = negative)
Overflow Behavior
- Unsigned: Wraps from max to 0
- Signed: Wraps from max positive to min negative
- Always check for overflow in critical applications
- Use larger types or BigInt when needed
Choosing the Right Type
Use Unsigned When:
- Values can't be negative
- You need maximum positive range
- Working with binary protocols or pixel data
Use Signed When:
- Values can be positive or negative
- Representing differences or changes
- Mathematical operations involving subtraction
Common Integer Sizes
Type | Range | Use Cases |
---|---|---|
Uint8 / Int8 | 0-255 / -128 to 127 | Colors, small counts, flags |
Uint16 / Int16 | 0-65K / -32K to 32K | Audio samples, coordinates |
Uint32 / Int32 | 0-4B / -2B to 2B | IDs, timestamps, large counts |
Uint64 / Int64 | 0-18Q / -9Q to 9Q | Very large values |
Best Practices
- Choose the smallest type that fits your data for memory efficiency
- Check for overflow in operations that could exceed limits
- Be explicit about signed vs unsigned conversions
- Use TypeScript for type safety with TypedArrays
- Document your choice when it's not obvious
- Test edge cases: maximum values, minimum values, zero
- Consider BigInt for unbounded integers
What's Next?
Now that you understand how computers store integers, you're ready to explore:
- Floating-Point Numbers: How computers represent decimal numbers
- Buffers and Binary Data: Practical tools for working with binary data
- TypedArrays and ArrayBuffer: JavaScript APIs for working with specific integer types
- Bitwise Operations: How to manipulate individual bits
Understanding signed and unsigned integers is your foundation for working with binary data, network protocols, image processing, and low-level programming!