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.

The JavaScript this runs is number/round's own reference.ts with its types stripped. That is neither the file the registry serves nor the file its digest covers: both are TypeScript, and no browser runs TypeScript. It is also the only part of this page that needs JavaScript at all.

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

You get round and describeRoundFailure.

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 calls this contract settles, every answer computed in integer arithmetic beforehand.

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-half-cent-to-fixed-loses

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-smallest-half-cent-to-fixed-loses

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-half-cent-the-multiply-loses

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

#a-price-with-three-decimals

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

#a-sum-that-is-not-its-parts

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

#a-positive-half

Away from zero, which on a positive value is the direction everybody agrees on.

round(-0.5, 0) → expected -1, reason null

#a-negative-half

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

#a-negative-one-and-a-half

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

#a-negative-two-and-a-half

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

#just-below-a-tie

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

#an-amount-that-rounds-away-to-nothing

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

#a-negative-zero-stays-negative

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

#a-positive-amount-that-rounds-away-to-nothing

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

#a-carry-through-every-digit

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-carry-that-crosses-ten

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

#a-carry-that-crosses-a-hundred

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

#a-value-written-in-exponent-notation

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

#the-largest-double

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-double-carried-up

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-smallest-double-dropped

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

#a-place-count-of-negative-zero

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

#a-place-count-past-what-to-fixed-accepts

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

#a-place-count-larger-than-any-decimal

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

#every-place-already-there

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'

#not-a-number

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'

#positive-infinity

The same refusal for the same reason, and one message covers both without lying.

round(-Infinity, 2) → expected null, reason 'value-not-finite'

#negative-infinity

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-fractional-place-count

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'

#a-place-count-that-is-not-a-number

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'

#an-infinite-place-count

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-negative-place-count

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.

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.

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.

Written for node, browser, bun.