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.
Why 0.1 + 0.2 doesn’t equal 0.3
Section titled “Why 0.1 + 0.2 doesn’t equal 0.3”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);Every JavaScript number is a 64-bit double-precision float per the IEEE 754 standard, exact for integers but only approximate for fractions whose binary expansion never terminates, which is most of them.
So the engine stores the closest 64-bit binary fraction to 0.1 and to 0.2; adding those two approximations gives a value whose closest decimal printout is 0.30000000000000004.
The third line is the flip side. 1995 / 100 prints exactly 19.95 because 1995 is an integer, exact in binary, and dividing it lands on a value the engine prints cleanly.
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 integer-cents rule
Section titled “The integer-cents rule”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.95const 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.
The Number.is* boundary checks
Section titled “The Number.is* boundary checks”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 ±InfinityNumber.isInteger(parsed); // a whole number, no fractional partNumber.isSafeInteger(parsed); // a whole number under 2^53Number.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('')); // 0console.log(Number(' ')); // 0Both 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.
When to reach for BigInt
Section titled “When to reach for BigInt”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
BIGINTcolumns exceedNumber.MAX_SAFE_INTEGER, so parsing them as anumberrounds the low bits away and can collapse two distinct IDs into one. Read them asBigInt, 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 literalconst next = snowflake + 1n; // BigInt arithmetic — works// const broken = snowflake + 1; // TypeError: cannot mix BigInt and numberStay on number until a real overflow forces the switch.
Converting strings to numbers
Section titled “Converting strings to numbers”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.95Number('12px'); // NaNThe 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.
parseInt('19.95', 10); // 19 — lossy on fractions!parseInt('12px', 10); // 12The dimension-string reach. Reads the leading digits and ignores trailing garbage: right for CSS strings like '12px', wrong for user input, since it silently drops the .95 of 19.95. Always pass the radix 10.
parseFloat('19.95'); // 19.95parseFloat('12px'); // 12Rarely the right answer. Same “ignore trailing garbage” behavior as parseInt, but it returns a float. Number() with validation reads more clearly every time.
+'19.95'; // 19.95+'12px'; // NaNThe shortcut. Unary plus runs the same conversion as Number(input). Fine in a one-line callback, but Number(input) reads better in shared code, so that is the form this course writes.
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.
Sort each value into its storage type
Section titled “Sort each value into its storage type”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.
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.
Write the boundary function
Section titled “Write the boundary function”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)).
External resources
Section titled “External resources”A one-page reference for the IEEE 754 surprise in every mainstream language. Bookmark for the inevitable conversation with a teammate who hasn't seen it before.
The full table of supported currencies with their minor-unit decimal counts. This is the integer-cents rule in production: Stripe enforces it at the API boundary.
The exhaustive Number reference, including the full Number.is* family and the MAX_SAFE_INTEGER / EPSILON constants.
The BigInt surface along with its friction: the no-mixing rule, the JSON serialization caveat, and the conversion helpers. Worth reading once before you reach for it the first time.