typescript/number/round@1
round
Round a number to a fixed number of decimal places, or null when the call cannot be answered.
Install
npx toopo add number/round
Signature
type Round = (value: number, places: number) => number | null
6 522 bytes, one file
0 imports
31 settled cases
What it does
Rounds a number to a fixed number of decimal places in JavaScript and TypeScript, answering the decimal the caller wrote rather than the double the machine stored. The two spellings a caller reaches for first are wrong, silently: Number((1.005).toFixed(2)) answers 1 and Math.round(1.005 * 100) / 100 answers 1, because the double nearest 1.005 sits below it. Neither ever returns NaN, so nothing announces the loss - a half-cent goes missing and the total is short. Math.round carries a second, unrelated fault: it breaks a tie towards positive infinity, so Math.round(-0.5) is -0 and Math.round(-2.5) is -2, which makes a refund round the opposite way from the charge it reverses. The one spelling in the language that answers correctly is Intl.NumberFormat with a rounding mode, and it hands back a locale-formatted string: converting it back with Number is the third trap, because "1,000.01" is NaN. This contract answers a number, breaks every tie away from zero, and refuses rather than guesses.
The language ships toFixed, which answers a string and rounds the stored double rather than the decimal the caller wrote, and Math.round, which breaks a tie towards positive infinity rather than away from zero. Intl.NumberFormat with roundingMode: "halfExpand" agrees with this contract and answers locale-formatted text.
What it is for, and what it is not
Finite doubles carrying an amount somebody wrote down: prices, totals, tax lines, measurements, percentages, chart axes. It is not a formatter - the answer is a number and never text - not a decimal arithmetic library, not a currency type, not banker's rounding, and not a way to round to tens or hundreds, which is why a negative place count is refused rather than answered.
Try it on your own input
This calls round on whatever you type. What you type into a field is the value, character for character, and the form opens on the-half-cent-to-fixed-loses so there is a call that works to edit. What comes back is what the function answered, under the call it was made from — invisible characters are named there, so two inputs that look alike on screen do not print alike. The settled answer is on the case's own line below, and is deliberately not repeated here. When it answers nothing, describeRoundFailure is called on the same input and its reason is printed underneath: the two exports are one surface, and every input this contract turns down answers round alike.
Reference
Everything above answers whether this function does what you need. Everything below is what it is bound to do, in full. It is long on purpose, and none of it is folded away: a case a reader cannot find with their browser's own search is a case this catalogue did not really publish.
Signature
type Round = (value: number, places: number) => number | null
type DescribeRoundFailure = (value: number, places: number) => RoundFailureReason | null
A call fails for one of 3 reasons, and the set is frozen with the major version: "value-not-finite", "places-not-whole", "places-negative".
round(v, p) === null if and only if describeRoundFailure(v, p) !== null, for every v and p.
31 settled cases
Every one of them is named, frozen with the major version, and linkable. This is what the contract decides, one input at a time.
- The decimal a double cannot hold 5
- Which way a tie goes 5
- The sign of zero 3
- A carry that runs 3
- A magnitude no fixed notation holds 4
- The place count at its edges 4
- What is refused 7
The decimal a double cannot hold
The reason this contract exists. Each value below is one a caller typed and the machine stored as something slightly smaller, so a built-in that rounds what was stored answers a penny short.
round(1.005, 2) → expected 1.01, reason null
The canonical instance. The double nearest 1.005 is 1.00499999999999989..., so (1.005).toFixed(2) answers "1.00" and the multiply answers 1. A caller who wrote 1.005 wrote the decimal, and the decimal is what is rounded.
round(0.015, 2) → expected 0.02, reason null
The first value of the declared sweep that toFixed gets wrong, so the table carries the boundary the figure in theTraps starts counting from rather than only a memorable middle.
round(0.145, 2) → expected 0.15, reason null
The first value the multiply gets wrong, and it is not the first toFixed gets wrong. The two spellings fail on different values, which is why the contract names two traps and not one told twice.
round(2.675, 2) → expected 2.68, reason null
The instance every article about floating point uses, kept because a reader arriving from one of them looks for it and has to find this contract answering 2.68.
round(0.30000000000000004, 2) → expected 0.3, reason null
What 0.1 + 0.2 really is. Rounding is the usual repair for it, and this row says the repair works: seventeen significant digits in, two out, with nothing left of the error.
Which way a tie goes
round(0.5, 0) → expected 1, reason null
Away from zero, which on a positive value is the direction everybody agrees on.
round(-0.5, 0) → expected -1, reason null
Where Math.round parts company: it answers -0, because it breaks a tie towards positive infinity. This contract is symmetric, so a refund of half a unit rounds to the same magnitude as the charge it reverses.
round(-1.5, 0) → expected -2, reason null
The same parting one unit along, where Math.round answers -1. Two rows rather than one because -0 could be read as a sign question and this one cannot.
round(-2.5, 0) → expected -3, reason null
The row that separates this contract from half-to-even as well as from Math.round. Banker's rounding answers -2 here and -2 for -1.5; this contract answers -3 and -2, and inputDomain says which of the two jobs it is for.
round(0.4999, 0) → expected 0, reason null
The other side of the boundary, so that the group settles where the tie *is* and not only what happens on it. Without it a contract that rounded everything up would satisfy the four rows above.
The sign of zero
A refund that rounds away to nothing keeps the mark saying which way the money went. It is why the contract compares answers with Object.is rather than with ===.
round(-0.001, 2) → expected -0, reason null
A tenth of a penny off an account, rounded to the penny. The answer is negative zero: the amount is gone and the direction is not, which is what a ledger line needs.
round(-0, 2) → expected -0, reason null
Nothing is dropped, so the value is answered back - and String(-0) is "0", so an implementation reading the sign off the text loses it here and nowhere else.
round(0.001, 2) → expected 0, reason null
The row that makes the one above mean something. An implementation answering -0 to everything would pass both rows before it, and fails here.
A carry that runs
round(0.9999999999999999, 15) → expected 1, reason null
Sixteen nines, every one of which carries. The answer is one digit longer than the digits kept, which is the case an increment written as an arithmetic add gets wrong at the seventeenth digit.
round(9.995, 2) → expected 10, reason null
A tie whose carry changes the number of digits before the point as well as after it, so an implementation splitting the string on the decimal point has to put it back somewhere else.
round(99.995, 2) → expected 100, reason null
The same crossing one order up, where the carry runs through two nines rather than one. Both rows are here because a carry that stops after one digit passes the first and fails this.
A magnitude no fixed notation holds
Values whose shortest decimal is written in exponent notation, at both ends of the double range. toFixed refuses some of these outright.
round(1e+21, 2) → expected 1e+21, reason null
The threshold where String stops writing digits and starts writing 1e+21. An implementation reading the shortest decimal has to read that form, and one that splits on the decimal point alone reads the exponent as a fraction.
round(1.7976931348623157e+308, 0) → expected 1.7976931348623157e+308, reason null
Nothing to drop at any non-negative place count, so the value is answered back. It is here because an implementation that reassembles unconditionally can overflow to Infinity, which the contract forbids.
round(5e-324, 323) → expected 1e-323, reason null
The smallest positive double, rounded one place above itself. The answer is the next denormal up, so the row settles that the rule keeps working where the doubles stop being evenly spaced.
round(5e-324, 0) → expected 0, reason null
The same value with every digit dropped. The dropped part opens with a zero rather than the five, because the five sits 324 places down - so it rounds to nothing, and an implementation looking at the first significant digit instead of the first dropped one answers 1.
The place count at its edges
round(1.5, -0) → expected 2, reason null
Negative zero is a count of zero, not a negative count: Number.isInteger(-0) is true and -0 < 0 is false. This row exists because it is settled by no property of this contract - measured, a perturbation refusing it reddened none of the ten guards, so the decision would otherwise be kept by nothing.
round(1.5, 400) → expected 1.5, reason null
Where this contract and toFixed disagree about whether there is a question. (1.5).toFixed(400) throws a RangeError; asking for more places than the value carries is a request that changes nothing, and the answer is the value.
round(1.5, 1e+21) → expected 1.5, reason null
The same question with a place count no string could ever be built to. It is answered without building one, which is why there is no places-out-of-range in the reason set.
round(19.99, 2) → expected 19.99, reason null
The ordinary shape of the same thing, and most of the calls a real caller makes: a column of amounts normalised to two places, of which nearly all are already at two places.
What is refused
round(NaN, 2) → expected null, reason 'value-not-finite'
There is no decimal to round. Answering NaN would let the value keep travelling, which is the whole reason number/parse@1 refuses to produce one.
round(Infinity, 2) → expected null, reason 'value-not-finite'
The same refusal for the same reason, and one message covers both without lying.
round(-Infinity, 2) → expected null, reason 'value-not-finite'
Both infinities rather than one, because an implementation testing value === Infinity passes the row above and fails this.
round(1.5, 1.5) → expected null, reason 'places-not-whole'
A place count is a count. Half a decimal place is a bug in the calling code, and the reason says so rather than guessing at one or two.
round(1.5, NaN) → expected null, reason 'places-not-whole'
The shape a place count reaches when it is computed from something absent. Number.isInteger(NaN) is false, so one test covers it with the row above.
round(1.5, Infinity) → expected null, reason 'places-not-whole'
The third shape the same test covers, and the one that separates this reason from a range check: a finite place count of 1e21 is answered and an infinite one is refused.
round(1.5, -1) → expected null, reason 'places-negative'
A caller asking to round to tens. It is a real job and it is not this one - inputDomain refuses it in as many words - so the reason is its own rather than folded into the one above: that repair is upstream in the calling code, and this one is to reach for a different tool.
Properties
2 of these 4 are checked on 1 000 generated cases per run, re-seeded each time. The other 2 are not applicable to this contract, and each says why below.
never mutates its arguments — not applicableThe signature takes two
numberarguments, primitives immutable by construction in JavaScript. No implementation, correct or broken, can violate this, so a test asserting it would be structurally incapable of failing.deterministic — checkedViolable in practice, and witnessed by an implementation that hoists the carry out of the increment loop - the shape a variable reaches when it is made "reusable" - so that a carry left by an earlier call decides this one and two identical consecutive calls answer differently. Two caches keyed on one of the two arguments were measured as candidates and both were rejected: the probe primes them itself, so the pair of calls agrees and the defect surfaces on the specific properties instead. A third was rejected for a reason worth carrying past this contract - an accumulator that appends rather than replaces drives the digit string past what a double holds, both calls answer Infinity, and
outputsAreEqualisObject.is, which judges two infinities equal. On a numeric contract an overflow can absorb a determinism signal. This property is ordered underno ambient inputrather than independent of it: every mutant measured to redden it reddens that one too, and the memoise-last mutant reddens that one and not this.no ambient input — checkedViolable in practice: this contract reads two numbers and answers a third, so the call history is the only ambient input it can plausibly acquire. Witnessed by an implementation holding the last call answered and serving it whenever the value matches, which answers the previous place count - the shape a caller rounding one amount to two places and then to none would meet. The property interleaves a probe with an arbitrary history and requires the probe to answer identically either way. Measured: a memo keyed on the value alone stays invisible to it, because the probe primes the memo before the history runs, which is the same limit
number/parse@1records of its own caches.no ambient output — not applicableNot reachable by a property - a test cannot observe a write that happened before it ran, and a correct memoising cache is indistinguishable from a defect by behaviour alone.
Benchmark profiles
The shapes of input an implementation is timed on. No figures yet: there is no reference machine, and a number produced on a developer laptop would be a number with nothing behind it.
money-to-the-cent — shortenedAmounts with a third decimal rounded to two places, the dominant shape in pricing, tax and invoicing code.
half-cents — at-a-tieValues sitting exactly on the half-cent, where the rounding rule is the whole answer and every built-in spelling parts from this contract.
nothing-to-drop — already-exactValues already carrying no more decimals than the caller asked for. Timed apart because an implementation reading the shortest decimal answers these without rounding anything, and a caller normalising a column of amounts hits this path on most rows.
seventeen-significant-digits — shortenedValues whose shortest decimal is as long as a double gets, to expose an implementation whose cost grows with the digit string rather than with the places asked for. The last one carries every digit through the increment, since rounding it grows the string.
refused-calls — refusedCalls that must answer null. Measured separately because an implementation may take a very different path when it refuses, and a caller validating user input hits that path most.
How this contract measured
2 batteries break this implementation on purpose, 36 times, and require the contract's own suite to notice each time. 33 of those defects were caught.
3 cells survived, and what did is here rather than on a page of its own, because what a suite did not catch is a fact about this function. Each carries the battery's own account of why, in the instrument's words and not this page's.
3 — equivalent
the edit cannot change an answer, so no test could catch it. Measured rather than assumed: each one carries what was compared against what.
number-round · RD-05 on C/as-committed
reads the sign off the comparison alone, dropping the Object.is(value, -0) disjunct. It survives because the early return is in front of it: negative zero has the decimal 0 at scale zero, so toDrop is -places and is never positive for a place count this contract accepts - the value is answered back before the sign is ever consulted. So the disjunct states an intent and carries no behaviour, and a-negative-zero-stays-negative guards the contract rather than this path. measured differentially against the reference over 2 000 001 values at four place counts and twenty-five traps at twenty-one: zero disagreements
number-round · RD-09 on C/as-committed
strips no leading zero from the digit string, so String(0.001) contributes "0001" rather than "1". It survives because a leading zero moves both terms that read the string together: it can only ever come from whole === "0", so digits.length and the index first is taken at shift by the same amount, and Number ignores it in the answer. measured differentially against the reference over 2 000 001 values at four place counts and twenty-five traps at twenty-one: zero disagreements
number-round · RD-11 on C/as-committed
indexes past the left edge of the digit string rather than answering a zero there. It survives because undefined >= "5" is false, which is exactly what "0" >= "5" already was - so the explicit default states what happens past the edge and carries no behaviour of its own. measured differentially against the reference over 2 000 001 values at four place counts and twenty-five traps at twenty-one: zero disagreements
What you can check yourself
This definition is frozen. Its canonical text hashes to 7418dfc5de093b77643d5be7852083c24fbf7fb3ab0f89056b6a0983ad97bc12, and the 8 files of its test harness are listed inside it with their own hashes — so a copy of the harness can be checked against this definition before it is trusted, then run against any implementation, without taking our word for any of it.