Before you begin
Why this matters
Whole numbers in TypeScript seem to have no limit, and they are exact for every count you are likely to type. Ids from other systems and large amounts in small units can pass the limit, and then arithmetic is wrong without any error. You will also meet % giving a negative answer.
What this helps you reason about
- Say which whole numbers a number holds exactly, and when to use bigint.
- Predict what a Uint8Array or an Int8Array stores for a value that does not fit.
- Predict Math.trunc(-7 / 2) and -7 % 2, and get a remainder from 0 to n - 1.
A travel app adds up miles and points, and counts days forward and back. This lesson asks which whole numbers a TypeScript number holds exactly, what happens past them, how negative numbers are stored in bits, and how / and % treat negative numbers.
Already know this? The short version
- number is a 64-bit float: every integer up to Number.MAX_SAFE_INTEGER (2^53 - 1) is exact; 2 ** 53 + 1 === 2 ** 53, with no error. Check with Number.isSafeInteger.
- Typed arrays are fixed-width: Uint8Array 0 to 255, Int8Array -128 to 127 in two’s complement, and so on; a stored value keeps its low bits, so 260 becomes 4. Bitwise operators work on 32-bit integers.
- bigint (10n, BigInt(x)) never overflows, and TypeScript refuses to mix it with number in arithmetic.
- Math.trunc(-7 / 2) is -3; % takes the sign of the dividend, the number being divided, so -7 % 2 is -1; ((x % n) + n) % n gives 0 to n - 1.
Words in this lesson (7)
- fixed-width integer
- An integer type that always uses the same number of bits, such as 8, 16, 32, or 64. N bits have 2 to the power N patterns, so the type has a smallest and a largest value.
- safe integer
- A whole number from -(2^53 - 1) to 2^53 - 1. A TypeScript number holds each of them exactly; beyond them, some whole numbers cannot be stored.
- bigint
- TypeScript’s type for whole numbers of any size, written with n after the digits, such as 10n. It never overflows, and it cannot be mixed with number in arithmetic.
- two’s complement
- The usual way to store a signed integer: the top bit counts as minus its place value. In 8 bits, 10000000 is -128, 11111111 is -1, and 01111111 is 127.
- overflow
- A result of integer arithmetic that is larger than the largest value of its type, or smaller than the smallest.
- wrap around
- What many integer types do on overflow: keep the low bits of the true result and drop the rest. Counting up past the largest value continues from the smallest.
- remainder
- What is left over after integer division. 7 divided by 2 is 3 with a remainder of 1, because 3 × 2 + 1 = 7.
Examples are focused snippets; imports, enclosing functions, or classes may be omitted. C examples use C11; Haskell examples use Haskell 2010. Expand the notes for language-specific depth.
01 / Range
How many whole numbers can TypeScript store exactly?
Bits, bytes, and binary showed that 8 bits have 256 patterns, read as the numbers 0 to 255. The same counting works for any number of bits. Each extra bit doubles the number of patterns, so N bits have 2 to the power N of them: 65,536 for 16 bits, about 4.3 billion for 32 bits, and about 1.8 × 10^19 for 64 bits.
An integer type that always uses the same number of bits is a fixed-width integer. It has a fixed number of patterns, so it has a smallest and a largest value, and nothing outside them fits. A TypeScript number is not a fixed-width integer. As What is a value? noted, every number is a 64-bit floating-point number. It holds every whole number exactly from -(2^53 - 1) to 2^53 - 1, that is, up to 9,007,199,254,740,991, which is named Number.MAX_SAFE_INTEGER. These are the safe integers. A larger number can still be stored, but not every whole number has its own pattern there.
Two other kinds of value hold integers. A typed array, such as the Uint8Array met in Bits, bytes, and binary, stores each element as a fixed-width integer: Uint8Array uses 8 bits per element, Uint16Array 16, and Uint32Array 32. A bigint is a whole number of any size, written with n after the digits, such as 10n. ** raises a number to a power: 2n ** 64n is 2 to the power 64.
console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991
const seats = new Uint8Array([0, 255]); // 8 bits each: 0 to 255
const counts = new Uint16Array([65535]); // 16 bits: 0 to 65535
const big = 2n ** 64n; // a bigint
console.log(big); // 18446744073709551616n A number is exact for every whole number up to 2^53 - 1; typed arrays hold fixed-width integers, and a bigint has no limit.
Precisely: how big a number can be
A number can be as large as Number.MAX_VALUE, about 1.8 × 10^308, but above 2^53 the gaps between stored numbers grow: 2 apart up to 2^54, 4 apart up to 2^55, and so on. A bigint uses as much memory as its value needs, and JSON.stringify throws an error for one.
02 / Signs
How is a negative number stored in bits?
An unsigned type holds only 0 and positive numbers. A signed type holds negative numbers too, and gives half of its patterns to them.
Almost every machine stores signed integers in two’s complement. In this form, the top bit, the one with the largest place value, counts as negative. In 8 bits, the places are worth 1, 2, 4, 8, 16, 32, and 64, and the top place is worth -128 instead of +128. So 01111111 is 127, the largest value; 10000000 is -128, the smallest; and 11111111 is -128 + 127 = -1. A 1 in the top bit means the number is negative. With 16 bits, a signed type holds -32,768 to 32,767; with 32 bits, -2,147,483,648 to 2,147,483,647.
Int8Array is the signed partner of Uint8Array: each element uses the same 8 bits, read in two’s complement. Uint8Array.of(255) and Int8Array.of(255) both store the pattern 11111111; the first reads it as 255, and the second as -1. Int16Array and Int32Array work the same way with more bits.
const unsigned = Uint8Array.of(255, 128, 127);
const signed = Int8Array.of(255, 128, 127);
console.log(unsigned[0], unsigned[1], unsigned[2]); // 255 128 127
console.log(signed[0], signed[1], signed[2]); // -1 -128 127 In two’s complement, the top bit counts as negative: 11111111 is -1 as a signed byte and 255 as an unsigned one.
Coming from Java: Java’s byte is always signed; in TypeScript, the array type decides: Uint8Array reads a byte as unsigned, Int8Array as signed.
Precisely: bitwise operators
A number itself is not stored in two’s complement; it has a separate sign bit. Bitwise operators such as |, &, and >>> first convert a number to a 32-bit integer, keeping its low 32 bits, and then work in two’s complement: -1 >>> 0 is 4294967295.
Experiment
Read one pattern as unsigned and as signed
Uint8Array.of and Int8Array.of make a typed array from the numbers given, and each element keeps the low 8 bits of its number. A Uint8Array reads them as unsigned, an Int8Array as signed. In two’s complement the top bit is worth -128 instead of +128. 11111111 is -128 + 127 = -1, and 10000000 is -128 alone. 127 is 01111111, so both read it as 127. 129 is 10000001: -128 + 1 = -127. The panel shows a typed array by its positions, 0, 1, and 2. Step sometimes stops on the same line twice; that is normal for this debugger.
Why it matters A pattern is negative only when it is read as a signed type. The same bits are a large positive number when read as unsigned.
Predict the second printed line. Then change the last 127 on the Int8Array.of line to 129 and predict its last number.
const unsigned = Uint8Array.of(255, 128, 127);
const signed = Int8Array.of(255, 128, 127);
console.log(unsigned[0], unsigned[1], unsigned[2]);
console.log(signed[0], signed[1], signed[2]);
What to look for
Step past lines 1 and 2: unsigned shows {0: 255, 1: 128, 2: 127}, and signed shows {0: -1, 1: -128, 2: 127}. The run prints 255 128 127, then -1 -128 127. After the edit, signed shows 2: -127, and the second line ends in -127.
Expected output
255 128 127
-1 -128 12703 / Overflow
What happens when a result passes the edge of the range?
A result past the largest value of its type, or below the smallest, is an overflow. The true answer exists, but the type has no pattern for it. TypeScript has two different edges.
A typed array element wraps around. Storing a value that does not fit keeps its low bits, so a Uint8Array element given 250 + 10 holds 4. Nothing reports the change.
A number loses exactness instead. From 2^53 to 2^54, stored numbers are 2 apart, so 2^53 + 1 has no pattern of its own and is rounded to 2^53. Past the safe range, arithmetic quietly gives a nearby number instead of the true one. Number.isSafeInteger(x) is true while x is still a safe integer.
const seats = new Uint8Array([250]);
seats[0] = seats[0] + 10;
console.log(seats[0]); // 4
const points = 2 ** 53;
console.log(points + 1 === points); // true
console.log(Number.isSafeInteger(points)); // false Past 2^53 - 1, a number silently skips whole numbers; a typed array element silently wraps around.
Coming from Python: Python’s int never overflows and stays exact; a TypeScript number stays exact only up to 2^53 - 1, so use bigint where Python would keep counting.
Precisely: Infinity
A result larger than Number.MAX_VALUE becomes Infinity. Long before that, every whole number past 2^53 is already rounded, so a sum of large ids or amounts can be wrong by a few units without looking wrong.
Experiment
Add 1 past the safe range
From 2^53 up to 2^54, a number can only be even, so 2^53 + 1 has no pattern of its own. It is rounded to the nearest number that has one, 2^53. Below 2^53, every whole number has its own pattern, so 2^52 + 1 is exact. Nothing reports the rounding.
Why it matters Above Number.MAX_SAFE_INTEGER, adding 1 can change nothing. Keep whole numbers below 2^53, or use bigint.
Predict whether after is different from points. Then change 2 ** 53 to 2 ** 52 and predict again.
const points = 2 ** 53;
const after = points + 1;
console.log(after, after === points);
What to look for
Step past line 1: points shows 9007199254740992, which is 2^53. Step past line 2: after shows 9007199254740992 as well. The run prints 9007199254740992 true. After the edit, points shows 4503599627370496, after shows 4503599627370497, and the run prints 4503599627370497 false.
Expected output
9007199254740992 true04 / Staying in range
How do you keep a result inside the range?
Choose a kind of number with room for every value the program can produce. For counts, and for money in cents, numbers are exact far beyond any real total. Values that come from elsewhere, such as ids from a server, can pass 2^53; check them with Number.isSafeInteger.
Where whole numbers can grow past 2^53, use bigint. BigInt(x) converts a safe integer, and arithmetic between two bigints is always exact. TypeScript refuses to mix the two kinds: with points a bigint, points + 1 gives the compile error Operator '+' cannot be applied to types 'bigint' and '1'. For a typed array, choose one with wider elements, such as Uint32Array, or a plain array of numbers.
const id = 9007199254740993n;
console.log(id + 1n); // 9007199254740994n
console.log(Number(id)); // 9007199254740992: not exact
console.log(Number.isSafeInteger(2 ** 53 - 1)); // true Use numbers for whole values below 2^53, bigint for larger ones, and check outside values with Number.isSafeInteger.
Precisely: bigint division
Number(id) converts a bigint to the nearest number, and BigInt(2.5) throws a RangeError, because 2.5 is not a whole number. bigint division cuts off the fraction: 7n / 2n is 3n.
Experiment
Add 1 to a bigint past 2^53
A bigint stores every whole number exactly, however large, so 2^53 + 1 has its own value. TypeScript refuses arithmetic that mixes a bigint and a number, because the result would have to be one of them, and either one could lose something.
Why it matters Use bigint for whole numbers that can pass 2^53, and write every number in that arithmetic with n.
Predict both printed values. Then change points + 1n to points + 1 and predict whether the program runs.
const points = 2n ** 53n;
const after = points + 1n;
console.log(after, after === points);
What to look for
Step past line 1: points shows 9007199254740992n; the n marks a bigint. Step past line 2: after shows 9007199254740993n, and the run prints 9007199254740993n false. After the edit, nothing runs; the compiler reports Line 2: Operator '+' cannot be applied to types 'bigint' and '1'.
Expected output
9007199254740993n false05 / Division
What do division and remainder do with negative numbers?
Dividing two integers gives an integer, in many languages, but not in TypeScript. The remainder is what is left over: 7 divided by 2 is 3 with a remainder of 1, because 3 × 2 + 1 = 7. With negative numbers, the language must choose which way to round, and the remainder follows that choice.
/ on two numbers keeps the fraction, so -7 / 2 is -3.5. Math.trunc cuts the fraction off, which rounds toward zero: Math.trunc(-7 / 2) is -3, not -4. Math.floor rounds down instead, to -4. % gives the remainder that goes with Math.trunc, and its sign follows the number being divided: -7 % 2 is -1, because -3 × 2 + -1 = -7. So x % 2 === 1 is false for every negative odd number; test x % 2 !== 0 instead.
When the answer must be a position from 0 to n - 1, counted round and round, a negative remainder is wrong. ((x % n) + n) % n gives a value from 0 to n - 1 for any x: the first % gives a value from -(n - 1) to n - 1, adding n makes it 1 or more, and the second % brings it back below n.
const offset = -7;
console.log(Math.trunc(offset / 2)); // -3
console.log(Math.floor(offset / 2)); // -4
console.log(offset % 2); // -1
console.log(((offset % 3) + 3) % 3); // 2 % keeps the sign of the number being divided. When you need a remainder from 0 to n - 1, make it so explicitly.
Coming from Python: Python’s // rounds down and its % takes the sign of the divisor: -7 // 2 is -4 and -7 % 2 is 1. Here % gives -1, so (start + day) % 7, which is right in Python, can give a weekday below 0.
Precisely: % never fails
x % 0 is NaN, and x / 0 is Infinity, with no error. % on two bigints follows the same sign rule: -7n % 2n is -1n.
Experiment
Divide -7 by 2
Math.trunc cuts -3.5 toward zero. The quotient is rounded toward zero, so -3.5 becomes -3. The remainder that goes with it is -1, because -3 × 2 + -1 = -7: it takes the sign of the number being divided. -1 is not 1, so the test for an odd number says no. Every odd number has a remainder that is not 0, whatever its sign.
Why it matters Test for an odd number with a remainder that is not 0, never with a remainder equal to 1.
Predict the three printed values; is -7 odd by the test left === 1? Then change left === 1 to left !== 0 and predict again.
const offset = -7;
const half = Math.trunc(offset / 2);
const left = offset % 2;
console.log(half, left, left === 1);
What to look for
Step past each line: offset shows -7, half -3, and left -1. The run prints -3 -1 false. After the edit, the run prints -3 -1 true.
Expected output
-3 -1 false06 / Try it
Can you explain a fresh example?
Work out each value at the edge of its type, then each division. Equality and comparison asks when two values count as the same, including floating-point numbers, which are not stored exactly.
const seats = new Uint8Array([250]);
seats[0] = seats[0] + 10;
const late = -9;
console.log(seats[0], Math.trunc(late / 4), late % 4, ((late % 4) + 4) % 4);
What is printed?
Reveal the reasoning
4 -2 -1 3. A Uint8Array element holds 0 to 255; 250 + 10 is 260, and storing it keeps the low 8 bits: 260 - 256 = 4. Math.trunc(-9 / 4) cuts -2.25 toward zero, to -2. -9 % 4 takes the sign of -9: -2 × 4 + -1 = -9. Adding 4 and taking % 4 again gives the position from 0 to 3, which is 3.
Practice tickets
- INT-101 · The season passenger total goes wrong after a busy summer → Free
- INT-102 · Packing days before a trip show a weekday below 0 → Sign in · Pro
- INT-103 · The halfway break is wrong on coaches with high readings → Sign in · Pro