Skip to content
Chapter 1Lesson 3

Store cents, not dollars

How JavaScript represents money safely, from floating-point numbers to BigInt and boundary validation.

Type 0.1 + 0.2 into a browser console and you get 0.30000000000000004. Every JavaScript number is a 64-bit floating-point value, and most decimal fractions have no exact binary form, so most decimal arithmetic comes back slightly wrong. That error is behind real billing bugs: an invoice total that drifts by a cent after summing a dozen line items. Two practices close the gap: store integer cents, never dollars (399, not 3.99), and validate that a value is a real number before it lands in the database.

Predict what these three lines print. The first two show the classic surprise; the third points to the fix.

Predict what this program prints, then press Check.

console.log(0.1 + 0.2);
console.log(0.1 * 3);
console.log(1995 / 100);

The bit counts don’t matter here; the consequence does. Fractional arithmetic in number is approximate, integer arithmetic is exact, and every decision about handling money in this stack follows from that one fact.

The rule that closes the bug class: store money as the integer count of the currency’s smallest unit — cents for USD, pence for GBP, and the minor unit defined per ISO 4217 for any other. Convert between that integer and a human-readable string only at the boundaries — accepting input and rendering for display. Never store, sum, multiply, or compare dollars-as-floats in between.

Your whole stack already agrees on this. Stripe’s API takes amount as an integer in the currency’s minor units: 1000 charges ten dollars, 10 charges ten yen. Unit 5, Postgres and Drizzle, stores it the same way, in an integer cents column, so a value round-trips from JS to Postgres and back without touching a float.

A string comes in from the user, an integer sits in storage, and a string goes back out for display:

const userInput = '19.95';
const dollars = Number(userInput); // 19.95
const cents = Math.round(dollars * 100); // 1995
// store `cents` as an integer; pass `cents` to Stripe; sum cents in SQL.
const displayDollars = (cents / 100).toFixed(2); // '19.95'
const displayString = `$${displayDollars}`; // '$19.95'

Number(userInput) parses the string into the same inexact 19.95. Math.round(dollars * 100) then does two jobs: multiplying moves the value into the cents domain, where it may land on 1995.0000000000002, and rounding erases that noise to leave the exact integer 1995. As an integer it now sums, multiplies, and compares exactly; on the way out, dividing by 100 and toFixed(2) recover the dollars.

Before a number lands in the database, confirm it is a finite integer, whether it came from a form, a JSON payload, or a calculation. JavaScript ships three predicates for that.

Number.isFinite(parsed); // not NaN, not ±Infinity
Number.isInteger(parsed); // a whole number, no fractional part
Number.isSafeInteger(parsed); // a whole number under 2^53

Number.isFinite(x) rejects NaN, Infinity, and -Infinity, the three values that otherwise propagate silently and surface days later as $NaN on an invoice. Follow every Number(input) with this guard.

Number.isInteger(x) confirms a whole number with no fractional part, and returns false for NaN and the infinities too. Use it when a value should already be an integer on arrival, like a cents amount or quantity from another service.

Number.isSafeInteger(x) checks against Number.MAX_SAFE_INTEGER (2^53 − 1), the ceiling past which a number can no longer represent integers exactly. Cents amounts stay under it until about $90 trillion, so this mainly guards values from 64-bit external systems. When it fails, reach for BigInt, covered next.

One footgun before you trust Number() as a parser:

console.log(Number('')); // 0
console.log(Number(' ')); // 0

Both return 0, not NaN, so an empty form field passes Number.isFinite and charges $0.00 silently. Reject empty input by checking the trimmed string before converting:

const parseCentsInput = (input: string): number => {
if (input.trim() === '') throw new Error('Empty input');
const dollars = Number(input);
if (!Number.isFinite(dollars)) throw new Error('Not a number');
const cents = Math.round(dollars * 100);
if (!Number.isSafeInteger(cents)) throw new Error('Amount too large');
return cents;
};

The previous lesson’s Number.isNaN answers only “is this exactly NaN?”; at a boundary, Number.isFinite is usually what you want, since it covers NaN and both infinities in one call.

Money never triggers BigInt. Integer cents stay under 2^53 up to about $90 trillion per transaction, far past any single charge, so number already covers it and BigInt would be friction with no payoff.

Three situations are the genuine reach for BigInt:

  • 64-bit external IDs. Twitter Snowflake IDs and some database BIGINT columns exceed Number.MAX_SAFE_INTEGER, so parsing them as a number rounds the low bits away and can collapse two distinct IDs into one. Read them as BigInt, or as a string if you never do arithmetic on them.
  • Counters that cross 2^53. Nanosecond timestamps over decades, or cumulative counters in high-volume telemetry.
  • Crypto and hash arithmetic. Modular exponentiation, large-prime work, and hash construction, where values are deliberately huge and overflow is silently disastrous.

Write a BigInt as a numeric literal with an n suffix:

const snowflake = 1234567890123456789n; // `n` suffix is the BigInt literal
const next = snowflake + 1n; // BigInt arithmetic — works
// const broken = snowflake + 1; // TypeError: cannot mix BigInt and number

Stay on number until a real overflow forces the switch.

Every entry point that takes a string as a number (form input, query param, JSON field) gets the same shape: Number(input) to parse, then Number.isFinite(result) to validate, plus the empty-string guard from the section above.

JavaScript ships four ways to turn a string into a number. Compare them on a clean '19.95' and a '12px' with trailing garbage:

Number('19.95'); // 19.95
Number('12px'); // NaN

The default. Strict: it returns NaN unless the whole string is a number, exactly what the Number.isFinite guard expects. Reach for it unless one of the other three has a specific trigger.

Picking the right function is only half of it, because NaN propagates: every operation that touches it yields NaN, so Math.round(NaN) is still NaN. One bad input spreads silently and surfaces later as $NaN on an invoice or a null in a database column. Catch it at the boundary, where the string enters, and the rest of your code never has to.

Drag each value into the storage type a senior would pick for it. Drag each item into the bucket it belongs to, then press Check.

number Fits in a regular number, no special handling
Integer cents A count of the smallest currency unit, stored as a number integer
BigInt Needs BigInt to avoid overflow or precision loss
A user’s shopping cart total in USD
A Twitter Snowflake ID from an external API
A product’s quantity in stock
A price in EUR shown on a checkout page
An exponential backoff delay in milliseconds
A database row count returned from SELECT COUNT(*)

Money goes to integer cents, since floating point can’t store most decimals exactly. Counts and durations fit in a regular number. BigInt is for values past Number.MAX_SAFE_INTEGER.

Implement dollarsToCents(input): convert a dollar string to integer cents, and reject every invalid input with a clear error.

Implement dollarsToCents(input) using input.trim() to check for emptiness, Number() to parse, Number.isFinite to reject NaN/Infinity, Math.round to land on integer cents, and Number.isSafeInteger to guard the upper bound. Throw 'Invalid amount' for non-numeric or empty inputs; throw 'Amount too large' for values past Number.MAX_SAFE_INTEGER.

    The empty-string and whitespace tests are load-bearing: Number('') is 0, so without an explicit input.trim() === '' guard they sail through as a zero-cent charge. That hole is also why the check uses input.trim() plus Number.isFinite instead of !isNaN(Number(input)).