Skip to content
koray.dev
Go back

Math.floor is not a floor

the missing step

A downstream system takes numeric fields as strings, at a decimal scale the receiver supplies per field, and the value must be floored, never rounded. Standard implementation:

const encodeAmount = (value: number, scale: number) =>
  String(Math.floor(value * 10 ** scale) / 10 ** scale);serialize.ts

Everyone writes this. It looks obviously correct. It survives the tests you would think to write, because the inputs that break it are ordinary decimals.

> 0.58 * 100
57.99999999999999
> Math.floor(0.58 * 100) / 100
0.57repl.txt

The nearest double to 0.58 sits a hair below it. Multiply and it stays below. Floor and you have dropped a whole step. Drain a balance to zero with this and it does not reach zero; something small is left behind, and nobody finds out from a log line.

the guard digit, and why it isn’t enough

The clever fix is to stop doing arithmetic: render one digit wider than you need, then cut the string.

const encodeAmount = (value: number, scale: number) => {
  const s = value.toFixed(scale + 1);
  const cut = s.slice(0, -1);
  return cut.endsWith(".") ? cut.slice(0, -1) : cut;
};serialize.ts

I shipped this, on the reasoning that toFixed can only round the digit you are about to throw away, so everything you keep is untouched.

That reasoning is wrong. Rounding the last digit carries.

> (9.99999).toFixed(3)      // asking for 2 decimals
'10.000'                    // → "10.00"
> (1.2999999).toFixed(3)
'1.300'                     // → "1.30"repl.txt

Both results are larger than the input. What this function does is round to scale + 1 and then truncate exactly: a floor with half a step of tolerance at the guard position. The tolerance is genuinely useful, because it is what absorbs the representation error and turns 0.58 back into 0.58. It is also not free: any value sitting within that tolerance of a boundary crosses it.

The first version lands one step low. The second occasionally lands high. Only one of those two spends money the account does not have.

Low is a residue and an awkward support thread. High is a request for more than the balance that was checked a moment earlier, rejected downstream if you are lucky and filled if you are not.

cut the string you already have

Every double has a canonical decimal form: the shortest string that round-trips back to it. String(value) gives you that, and for a form field or an API payload it is the number someone actually typed. Cutting it needs no arithmetic.

const encodeAmount = (value: number, scale: number) => {
  // Refuse rather than mangle. Negatives truncate toward zero here, which is
  // not a floor; above 1e21 every renderer switches to exponent notation.
  if (!Number.isFinite(value) || value < 0 || value >= 1e21)
    throw new RangeError(`not serialisable at scale ${scale}: ${value}`);

  // Only sub-1e-6 values still render exponentially, and toFixed is plain there.
  let s = String(value);
  if (s.includes("e")) s = value.toFixed(Math.max(scale + 1, 20));

  const dot = s.indexOf(".");
  if (dot === -1) return s;
  const cut = scale === 0 ? s.slice(0, dot) : s.slice(0, dot + 1 + scale);
  return cut.includes(".") ? cut.replace(/0+$/, "").replace(/\.$/, "") : cut;
};serialize.ts

Fuzzed over six hundred thousand values across seven scales, this never returns a value above its input and never lands more than one step below it. Both of the earlier versions do one or the other.

The three rejections matter more than the cut does. Each one is a case where the previous versions produced a confident, plausible, wrong string, and the whole argument of this post is that those are the expensive ones.

the exemption nobody reads

The same boundary caps a different field at a fixed number of significant figures, with a carve-out for whole numbers. Implement the headline rule, miss the carve-out, and a round-number input is silently rewritten to a nearby different number.

const encodeRate = (value: number) =>
  Number.isInteger(value)
    ? String(value)
    : trimZeros(value.toPrecision(SIG_FIGS));serialize.ts

An early return, above the rounding. Two caveats I would not have written the first time: toPrecision goes exponential once the integer part outgrows the budget, so this is only safe because values on this path are bounded well below that, and the trailing-zero strip is the third bug in this family. On the amount path, not this one, stripping zeros without first checking for a decimal point turned 4500 into 45. A hundredfold error, introduced by a tidy-up, caught in review because the diff was small enough to read.

characterization tests first

The order matters more than the fix. Write tests that encode today’s behaviour and the intended behaviour, run them, and confirm that exactly the expected set fails. If anything else fails, stop: the contract is not what you assumed and everything after that is speculation. Only then change one function at a time.

That is what caught the trailing-zero bug. It was not in the audit and not in the plan.

when this stops working

This is float in, string out. It guarantees the last step neither loses nor gains a step. It is not exact arithmetic, and if you are summing thousands of values before serialising, the error you care about happened long before this function.

It also takes the shortest round-trip decimal as the intent. That is right when the value came from a form or a payload. It is wrong when the value is the output of a long computation, where the extra digits are real and you meant to cut them. Same function, opposite correctness, depending on where the number has been.

written in İstanbul, february 2026 · EOF


Share this post:

Next Post
Idempotent writes don't make side effects idempotent