Store cents, not dollars
How JavaScript's floating-point numbers, BigInt, and boundary validation come together to represent money safely in a SaaS application.
Open a browser console and type 0.1 + 0.2. You’ll get 0.30000000000000004. That tiny tail of digits is behind real billing bugs: an invoice total that drifts by a cent after summing twelve line items, a customer’s $1.005 discount that rounds down to $1.00 when it should have rounded up, a Stripe charge that’s off by an amount no one in support can explain. Every JavaScript number is a 64-bit floating-point value, and most decimal fractions can’t be stored exactly in binary, so most decimal arithmetic comes back slightly wrong. This lesson gives you two things to fix that. The first is a structural rule that closes the bug class for money: store integer cents, never dollars. The second is a boundary discipline that keeps malformed numbers out of every database column you write to.
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 before reading on. The first two show the classic surprise, and the third points to the rule that fixes it.
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. That representation is exact for integers up to ±2^53−1, but only approximate for fractions whose binary expansion never terminates, which is most decimal fractions, 0.1 among them. So 0.1 isn’t really 0.1 inside the engine; it’s the closest 64-bit binary fraction to it, and the same goes for 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 produces a value the engine can print without that trailing noise. Integers stay exact while fractions drift, and that asymmetry is what the rest of this lesson works around.
You don’t need to memorize the mantissa bit count or the rounding modes. What matters is the consequence: fractional arithmetic in number is approximate, integer arithmetic is exact. Every decision about how to handle money in this stack follows from that one fact.
As a side note, this same standard is why NaN === NaN was false back in the previous lesson. IEEE 754 uses NaN as the universal marker for “this calculation was invalid.” Making it unequal even to itself guarantees that no accidental equality check can ever mistake it for a valid value.
The integer-cents rule
Section titled “The integer-cents rule”Here’s the rule that closes the bug class: store money as the integer count of the smallest unit of the currency. That means cents for USD, pence for GBP, and the appropriate minor unit per ISO 4217 for whatever currency you’re handling. Convert between that integer and a human-readable string only at the boundaries: when accepting input from a form, and when rendering for display. Never store, sum, multiply, or compare dollars-as-floats anywhere in between.
This isn’t a course preference; it’s the rule the production stack you’re about to build on already enforces. Stripe’s API takes amount as an integer in the currency’s minor units (1000 to charge ten dollars, 10 to charge ten yen), and every modern payment processor does the same. Unit 5 applies the rule again at the database boundary: money lives in an integer cents column, a Postgres bigint mapped through Drizzle, rather than a numeric dollars column. That way a value can round-trip from JS to Postgres and back without ever passing through a float. The wire format, the database, and the payment processor all agree on integers, and this lesson teaches you to write the application code that respects that agreement.
Here’s the round-trip a senior writes. 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'Three things are happening here. Number(userInput) parses the string into a float, and yes, that float is the same inexact 19.95 we just warned about; the next line fixes that. Math.round(dollars * 100) then does two jobs at once. The multiplication moves the value into the cents domain, where it may land on something like 1995.0000000000002 from the floating-point noise, and the rounding erases that noise to leave the exact integer 1995. From that point on the value is an integer, so it sums, multiplies, and compares exactly. On the way back out, dividing by 100 recovers the dollar value and toFixed(2) formats it for display. Storage is an integer; the boundaries are strings.
One forward pointer before moving on. The right tool for formatting currencies for human display is Intl.NumberFormat, which handles locale-aware symbols, grouping, and minor-unit decimals automatically ($1,234.56 in en-US, 1 234,56 € in fr-FR). The toFixed(2) shape above is fine for getting your first product to launch, and you’ll move to Intl.NumberFormat when display localization lands in a later unit. Either way, the storage rule doesn’t change.
The Number.is* family the rule leans on
Section titled “The Number.is* family the rule leans on”The integer-cents rule is only as good as the values you actually store. A string from a form, a JSON payload from another service, a number coerced from a calculation: each one needs to be confirmed as a finite integer before it lands in the database. JavaScript ships three predicates on the Number namespace that give you exactly that boundary-check vocabulary.
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) is the first check at any boundary that produces a number. It returns false for NaN, Infinity, and -Infinity, the three values that would otherwise propagate silently through every downstream calculation and surface days later as $NaN on an invoice. Make it a habit: every Number(input) is followed by a Number.isFinite guard.
Number.isInteger(x) confirms the value is a whole number with no fractional part. Reach for it when a value should already be an integer by the time you receive it (a cents amount read back from another service, a row count from a query, a quantity field on a JSON payload) and you want to verify that before trusting it. It also returns false for NaN and the infinities, so it stands on its own as a one-shot integer guard. You won’t see it inside the conversion function below, because there Math.round is what produces the integer, so a follow-up isInteger check would be redundant.
Number.isSafeInteger(x) checks the value against the boundary past which a regular number can no longer represent integers exactly. It returns false for integers larger than Number.MAX_SAFE_INTEGER (which is 2^53 − 1, or 9_007_199_254_740_991). Money in cents up to about $90 trillion fits comfortably under this ceiling, so in practice this check guards values coming in from 64-bit external systems. When a value fails it, that’s the signal to reach for BigInt, covered in the next section.
There’s one footgun in Number() worth calling out before you use it as the boundary parser everywhere:
console.log(Number('')); // 0console.log(Number(' ')); // 0Number('') and Number(' ') both return 0, not NaN. That means Number.isFinite(Number('')) is true, so an empty form field sails through your validation as a valid zero dollars. The result is a silent $0.00 charge in the database that leaves the user understandably confused. If empty input should be rejected, and it almost always should, check 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;};Read it top to bottom. The empty-string guard catches the Number('') === 0 footgun. Number.isFinite catches NaN and Infinity from malformed input. Math.round converts to integer cents and erases the multiplication’s floating-point noise. And Number.isSafeInteger guards the upper bound, past which values would lose precision in any later arithmetic. That’s four lines of validation at a single boundary, and nothing bad leaks downstream. In a later unit you’ll meet Zod, which folds this exact shape into a declarative schema (z.string().min(1).pipe(z.coerce.number().int().finite())). The runtime checks survive and the boilerplate disappears, but the boundary discipline stays the same.
One related note. Number.isNaN (from the previous lesson) answers the question “is this exactly the NaN value?”. At boundaries, though, Number.isFinite is usually what you actually mean, because it covers NaN and the two infinities in one call.
When BigInt earns its weight
Section titled “When BigInt earns its weight”Start with what does not trigger BigInt: money. Integer cents under 2^53 cover any individual transaction up to about $90 trillion. That’s well past anything Stripe will let you charge in a single API call (Stripe caps individual amount values at 8 digits, around $1M USD per call), and well past any customer balance a SaaS will plausibly see. Regular number already covers money; reaching for BigInt here is friction with no payoff.
Here are the three situations where BigInt is the right reach:
- 64-bit external IDs. Twitter Snowflake IDs and some database
BIGINTcolumns exceedNumber.MAX_SAFE_INTEGER, so they silently corrupt when the application parses them as a regularnumber: the lowest bits get rounded away, and two distinct IDs can collapse to the same value. Read them asBigInt, or as a string if you never need arithmetic on them. - Large counters that genuinely cross 2^53. Rare in SaaS, but it happens: nanosecond timestamps over multi-decade ranges, cumulative event counters in high-volume telemetry pipelines.
- Crypto and hash arithmetic. Modular exponentiation, large-prime work, hash construction: places where overflow is silently disastrous and the values are deliberately huge.
You 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 numberThe senior call fits in one sentence: use number until a real overflow forces the switch. Because BigInt carries a genuine cost, you pay it only where the risk of overflow is genuine too.
Converting strings to numbers
Section titled “Converting strings to numbers”Every entry point that takes a string and treats it as a number (form input, query parameter, JSON field, environment variable) gets the same shape: Number(input) to parse, then Number.isFinite(result) to validate, plus the empty-string guard from the section above. Everything else in this section is a specialized tool for a specific case.
JavaScript ships four ways to turn a string into a number, and only one of them is the default. Compare them against the same two inputs, a clean '19.95' and a '12px' with trailing garbage, and the differences become clear:
Number('19.95'); // 19.95Number('12px'); // NaNThe default. Strict: it returns NaN unless the whole string is a number. Pair it with Number.isFinite and the empty-string guard, and the boundary check is done. Reach for this form 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. Parses the leading digits and ignores trailing garbage. That’s exactly right for CSS strings like '12px' or '200rem', and almost always wrong for converting user input to a number, because it silently loses the fractional part of 19.95. The radix argument is required so old engines don’t guess at base-8 for leading zeros.
parseFloat('19.95'); // 19.95parseFloat('12px'); // 12Rarely the right answer. It has the same forgiving “ignore trailing garbage” behavior as parseInt, but returns a float. Its use cases are narrow enough that Number() with validation reads more clearly at the call site every time.
+'19.95'; // 19.95+'12px'; // NaNThe shortcut. The unary plus operator triggers the same conversion as Number(input), so the behavior is identical. It’s legible enough in a one-line callback, but Number(input) reads better in shared code, and that’s the form this course writes.
This matters beyond picking the right function, because NaN propagates. Every arithmetic operation involving NaN yields NaN: NaN + 1 is NaN, NaN * 2 is NaN, Math.round(NaN) is NaN. So a single bad input quietly spreads through every downstream calculation. It surfaces later as $NaN on an invoice, or as a null in a database column because some serializer choked on the NaN along the way. The fix is the boundary check (Number.isFinite right after Number(), with the empty-string guard upstream), not a defensive branch at every arithmetic call site downstream. Catch the bad value where it enters, and the rest of your code never has to handle it.
One last footnote: Number.parseInt and Number.parseFloat exist as namespaced aliases with behavior identical to the globals; neither form is preferred over the other.
Where each value lives
Section titled “Where each value lives”Confirm you can apply the rule to real SaaS values without re-deriving it from first principles. Drag each item into the bucket it belongs in.
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(*)The pattern across the three buckets is the same one the lesson has been building. Money, in any currency, goes to integer cents because the floating-point representation can’t store most decimals exactly. Most counts and durations fit comfortably in a regular number. BigInt is reserved for values from systems that exceed Number.MAX_SAFE_INTEGER, and even there you take on its friction knowingly.
Practice: write the boundary function
Section titled “Practice: write the boundary function”Now write the boundary function yourself. Implement dollarsToCents(input) so it converts a dollar string into an integer cents value and rejects every shape of invalid input with a clear error. The tests will reveal the footguns on your first wrong run.
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 the load-bearing ones. Without the explicit input.trim() === '' guard, Number('') returns 0, sails through Number.isFinite, gets rounded to integer 0, and your function returns a successful zero-cent charge for an empty form submission. That’s the bug you’re guarding against. It’s also why the boundary check is input.trim() plus Number.isFinite, rather than !isNaN(Number(input)), which would have the same hole. Once those eight tests pass, you’ve written the exact shape that’s about to repeat at every numeric boundary in the rest of the course.
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.