{0,0}

When numbers lie: Numeric precision in TypeScript

How are numbers represented inside a computer? Why 0.1 + 0.2 is not 0.3 and how it affects our applications.

When we study programming and software engineering we spend time studying the big picture: systems at scale, software architecture, design patterns and modular code. But once we face the real world we find that many problems don’t come from the architecture or the high-level design of our systems, but hide in everyday details that we can overlook far more easily than we think.

Some classic examples of this kind of “trap” are:

  • Dates, timestamps and time zones: The headache of managing UTC time on the server versus clients in different time zones, realizing too late that a database didn’t store the time zone correctly (or that we didn’t even consider it in the first place).
  • Precision when handling decimal numbers: Or, as I like to call it, assuming a computer can calculate 0.1 + 0.2 without any trouble.
  • Implicit default behaviors: those things that happen without us explicitly asking for them. For example:
    • In JavaScript Number("") returns 0 even though we never specifically asked it to convert an empty string to 0.
    • In SQL, the absence of ORDER BY clauses makes it look like data is ordered by creation time by default, but in reality it’s closer to a coincidence of the database engine. Even if we include ORDER BY created_at, we implicitly get an ASC (ascending order) by default.

While time zones and default behaviors deserve a deeper analysis, today we’ll focus exclusively on numeric precision. If your application handles money, token balances or fractional amounts, treating numbers without keeping this detail in mind is a time bomb.

Let’s explore why this happens, how systems like blockchain applications dodge it, and how to safely handle exact precision from TypeScript all the way to your PostgreSQL database.

The root of the problem: The 0.1 + 0.2 trap

console.log(0.1 + 0.2); // 0.30000000000000004

What happened here? Why isn’t the result 0.3? For humans this is absurd; for the computer it’s completely logical.

JavaScript uses the IEEE 754 standard for double-precision floating-point numbers.

It’s called “double precision” because historically it’s compared against another, smaller format: single precision. In IEEE 754, the most common formats are float (32 bits) and double (64 bits).

A 32-bit float is laid out as:

  • 1 bit → sign
  • 8 bits → exponent
  • 23 bits → fraction/significand

A 64-bit double uses:

  • 1 bit → sign
  • 11 bits → exponent
  • 52 bits → fraction/significand

Computers think in binary (base 2), while humans think in decimal (base 10). In base 10, certain fractions can’t be represented precisely; for example, 1/3 becomes a repeating decimal (0.3333…). Similarly, in binary, fractions like 1/10 (0.1) or 1/5 (0.2) become infinite, repeating binary fractions. Because hardware memory is finite, the computer must truncate that infinite sequence. That microscopic truncation is where rounding error is born. While a difference of 0.00000000000000004 may not matter when calculating an animation layout, it matters enormously when computing accounting ledgers or smart contract settlements.

Let’s look at this in detail by doing the conversions by hand.

From decimal to binary: multiply by 2

To convert the fractional part of a decimal number to binary, you repeatedly multiply by 2 and take the integer digit that appears at each step. With 0.625 (a “friendly” case):

0.625 × 2 = 1.25  → 1 (the fractional part is 0.25 and gets multiplied by 2 next)
0.25  × 2 = 0.5   → 0 (the fractional part is 0.5 and gets multiplied by 2 next)
0.5   × 2 = 1.0   → 1  (we reached 0, no fractional part left, we're done)

Reading the digits from top to bottom: 0.625 = 0.101 in binary. The representation is finite because 0.625 = 5/8, and 8 is a power of 2. That’s the general rule: a decimal fraction has a finite binary representation only if its (simplified) denominator is a power of 2.

How it ends up inside the double

With the number already in binary, building the double is a matter of filling in the three fields we saw above. First it gets normalized: the point is shifted until a single 1 is left on the left side (scientific notation, but in base 2). That leading 1 isn’t stored because it’s always there (it’s called the implicit bit).

The exponent comes straight from the normalization: it’s the number of places you moved the point (positive if you moved it to the left, negative if to the right). But since the exponent field has no sign bit of its own, IEEE 754 doesn’t store the real exponent but the exponent plus a bias of 1023. Why 1023? The field’s 11 bits allow 2048 values (0 to 2047); reserving the two extremes for special cases (0 for zeros and subnormals, 2047 for Infinity and NaN) leaves 2046 values to split evenly between negative and positive exponents: from −1022 to +1023. The bias 1023 (which is 2¹⁰ − 1, the midpoint of the range) shifts that whole range upward so it fits as unsigned integers. A bonus of this trick: two positive doubles can be compared bit by bit as if they were integers, with no extra logic for negative exponents.

For 0.625:

0.625 = 0.101₂ = 1.01 × 2⁻¹   (normalized)

sign        → 0                (positive)
exponent    → −1 + 1023 = 1022 → 01111111110
significand → 0100000000000000000000000000000000000000000000000000
              (the "01" that follows the implicit 1., padded with zeros)

And for a number with an integer part like 14.625 (= 1110.101₂, because 14 = 1110₂):

14.625 = 1110.101₂ = 1.110101 × 2³   (normalized)

sign        → 0                (positive)
exponent    → 3 + 1023 = 1026  → 10000000010
significand → 1101010000000000000000000000000000000000000000000000

In both cases there are bits to spare: the 52 significand bits are more than enough and the rest is padded with zeros. The number is stored exactly. The problem with 0.1 is precisely that its binary expansion never ends, so there’s no zero-padding possible: the 52 bits fill up with the repeating pattern and it has to be cut off.

Now the same process with 0.1:

0.1 × 2 = 0.2 → 0
0.2 × 2 = 0.4 → 0
0.4 × 2 = 0.8 → 0
0.8 × 2 = 1.6 → 1
0.6 × 2 = 1.2 → 1
0.2 × 2 = 0.4 → 0  (we're back at 0.2! the cycle repeats forever)

We never reach 0: 0.1 = 1/10, and 10 is not a power of 2. The result is infinite and repeating:

0.1 = 0.0001100110011001100110011...₂  (the "0011" pattern repeats infinitely)

Since a double only has 53 bits of precision for the significand (the 52 stored bits plus the implicit leading 1), the computer cuts that infinite sequence off and rounds the last bit. What it stores is no longer 0.1; it’s the closest possible approximation.

From binary to decimal: negative powers of 2

The reverse path is adding negative powers of 2 for each set bit: the first position after the point is worth 2⁻¹ = 0.5, the second 2⁻² = 0.25, the third 2⁻³ = 0.125, and so on.

0.101₂ = 1×0.5 + 0×0.25 + 1×0.125 = 0.625 ✓

If we apply this process to the 53 bits the computer actually stored for 0.1, we get its exact value. It’s a long decimal (though finite, because the stored bits are finite) and slightly greater than 0.1:

0.1 is stored as → 0.1000000000000000055511151231257827021181583404541015625
0.2 is stored as → 0.2000000000000000111022302462515654042363166809082031250

Notice that 0.2 carries twice the error of 0.1: in binary, 0.2 is exactly the same repeating sequence as 0.1 shifted one place, so its rounding error also doubles.

So why does console.log(0.1) print 0.1 and not that enormously long number? Because when printing, JavaScript picks the shortest decimal that rounds back to the same double. The error is there, just hidden behind the formatting.

The 0.1 + 0.2 addition, step by step

With this we can now reconstruct the crime scene:

  1. Before adding, there’s already error. The CPU doesn’t add 0.1 + 0.2; it adds the two approximations above, both slightly over their real value.
  2. The exact sum of those approximations is:
  0.1000000000000000055511151231257827021181583404541015625
+ 0.2000000000000000111022302462515654042363166809082031250
= 0.3000000000000000166533453693773481063544750213623046875
  1. That result doesn’t fit in 53 bits either, so it has to be rounded again to the nearest double. The two candidates are:
0.29999999999999998889776975374843459576368331909179687500  (the double that represents the literal 0.3)
0.30000000000000004440892098500626161694526672363281250000  (the next double)

The exact sum lands right at the midpoint between the two, and IEEE 754’s tie-breaking rule (round to the candidate whose last bit is 0, known as round half to even) picks the second one. The final stored result is 0.30000000000000004440....

  1. When printing, JavaScript looks for the shortest decimal that identifies that double: 0.30000000000000004. And since the literal 0.3 is stored as the other double (the one sitting just below), the comparison fails:
console.log(0.1 + 0.2 === 0.3); // false

In summary: there were three roundings (when storing 0.1, when storing 0.2 and when storing the sum) and the errors didn’t cancel each other out; they accumulated upward. It’s not a JavaScript bug: any language using 64-bit IEEE 754 (Python, Java, C++, Go…) gives exactly the same result.

Broadening the picture with BigInt

Besides number, JavaScript also has BigInt to handle arbitrarily large numbers. The difference lies in:

number

  • Represents 64-bit floating-point numbers (IEEE 754 standard).
  • Like all other floating-point number representations that follow IEEE 754, it can’t represent the whole universe of fractional numbers precisely.
  • Safe range: it can only safely represent integers up to 2^{53} - 1. This limit is available as Number.MAX_SAFE_INTEGER. For example:
console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991
console.log(Number.MAX_SAFE_INTEGER + 1); // 9007199254740992
console.log(Number.MAX_SAFE_INTEGER + 2); // 9007199254740992 -> not safe

BigInt

  • Arbitrary-precision integers. It can grow as much as your system’s available memory allows.
  • It doesn’t handle decimals natively. If you do 5n / 2n the result is 2n (appending n to the number is one way to tell JavaScript it’s a bigint; another option is using the BigInt() constructor). Also, you can’t mix number and bigint variables in math operations without explicit casting, or you’ll get an error like Uncaught TypeError: Cannot mix BigInt and other types, use explicit conversions.
  • As its name suggests, it only handles integers. It doesn’t support fractional parts.
console.log(BigInt(Number.MAX_SAFE_INTEGER) + 2n); // 9007199254740993n -> safe

How do blockchains solve it?

Now, problems like this must be present in any system that handles money, token balances or fractional amounts.

In a traditional fintech, if you miscalculate an amount, you often have layers to correct it: internal database, bank reconciliation, reversals, accounting adjustments, support, chargebacks or manual corrections. It’s still serious, but the system usually has administrative mechanisms to repair it.

On a blockchain, however, when you sign and send a valid transaction, that transaction can end up executed on-chain and there’s no “network administrator” to revert it. This, combined with the fact that they move large amounts of money (and increasingly so), forces us to have exact precision in our calculations.

That’s why ERC-20 (the fungible token standard on Ethereum) defines that each token has a fixed number of decimals (you can read more here). For example, USDC uses 6 decimals, which means 1 USDC equals 1,000,000 units. ETH uses 18 decimals, which means 1 ETH equals 1,000,000,000,000,000,000 wei. This means that a balance of 0.1 ETH is actually stored as 100,000,000,000,000,000 wei on the blockchain and only formatted as 0.1 ETH for better human comprehension.

This way, any fractional number can be represented exactly through the combination of an integer and a number of decimals. On the blockchain, each contract defines its own decimal scale.

Operations with Examples

1. Native fixed-point arithmetic with BigInt

To add 0.1 ETH and 0.2 ETH safely (without floating-point errors), we first scale to their integer representation (wei):

const decimals = 18n;
const scale = 10n ** decimals; // 1_000_000_000_000_000_000n

// Representation of 0.1 ETH and 0.2 ETH as BigInt
const amountA = 100000000000000000n; // 0.1 ETH
const amountB = 200000000000000000n; // 0.2 ETH

const totalWei = amountA + amountB; // 300000000000000000n

// Convert back to decimal only for UI display
const totalEth = Number(totalWei) / Number(scale); 
console.log(totalEth); // 0.3

2. Using math libraries for complex logic

BigInt is perfect for integer additions/subtractions, but dividing or calculating compound rates becomes complex because BigInt discards decimals. For more advanced logic, using specialized libraries like bignumber.js or decimal.js is recommended:

import BigNumber from 'bignumber.js';

// Exact decimal operations
const a = new BigNumber(0.1);
const b = new BigNumber(0.2);
console.log(a.plus(b).toString()); // "0.3"

// Safe parsing and divisions with raw 18-decimal balances
const ethBalanceRaw = new BigNumber("1234567890123456789"); // raw wei value
const ethUnit = ethBalanceRaw.dividedBy(new BigNumber(10).pow(18));

console.log(ethUnit.toString()); // "1.234567890123456789"

Storing long decimals: Strategy in PostgreSQL

When you carry high-precision data from your application to the database, choosing the right type determines whether your ledger preserves precision.

Option A: Store as String / VARCHAR

Common in blockchain indexers: the gigantic integer (like Solidity’s uint256) is stored directly as text.

Pros: No risk of losing precision or truncating during storage. Supports any number of digits.

Cons: You lose the ability to do efficient native database calculations (SUM(), AVG(), >, < ranges) without explicitly casting in every query.

Option B: Use native NUMERIC/DECIMAL

PostgreSQL offers NUMERIC types for exact, user-defined high precision.

CREATE TABLE asset_balances (
    id SERIAL PRIMARY KEY,
    -- Up to 78 total digits, with exactly 18 decimals
    token_balance NUMERIC(78, 18) NOT NULL
);

Going deeper: precision limits in Postgres

  • Maximum limit: up to 131,072 digits before the decimal point and 16,383 after.
  • What if I use a NUMERIC(1000, 18) “just in case”?
    Tempting, but it comes with costs:
    • Storage: NUMERIC is variable-length; it stores in blocks of 4 digits per 2 bytes + a 4-8 byte header. If your limit is huge, space usage and cache efficiency plummet.
    • Performance: All operations on NUMERIC are done in software in Postgres, not using the server’s floating-point hardware. The higher the precision, the higher the CPU load.
    • Indexes: Indexing gigantic NUMERIC columns will bloat your indexes, making reads and writes slower.

Standard practice: Choose realistic limits based on the assets you’ll handle. For the Ethereum ecosystem, NUMERIC(78, 18) is the standard (enough to support the maximum value of a Solidity uint256, ~$1.15 \times 10^77$).

Summary: Future-proof systems

To prevent numeric precision errors in production:

  1. API contracts with Strings: When transmitting large numbers or balances over JSON, always serialize them as strings; JSON parsers automatically convert numbers to floats and you can lose precision before it even reaches your code.
  2. Math in the right layer: Don’t use JS/TS number primitives for financial calculations. Use BigInt for scalar calculations, or libraries like bignumber.js for complex fractions.
  3. Explicit names: Avoid accidentally mixing units; use names like balanceInWei or amountInUsdcUnits instead of ambiguous balance or amount.
  4. Tailored database typing: Use NUMERIC(precision, scale) for accounts where you need math and aggregations; reserve VARCHAR/TEXT only for immutable event logs.

I hope I’ve contributed to your understanding of how computers handle numbers, integers and decimals. See you in the next post!