typescript/number/round@1 — Round a number to a fixed number of decimal places, or null when the call cannot be answered.

31 named edge cases, settled and frozen. TypeScript source copied into your project: one file, 6 522 bytes, no dependencies.

[toopo](../../../)

- [How we verify](../../../method/)

`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](../../../what-a-contract-is/), 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 5](#the-decimal-a-double-cannot-hold)
- [Which way a tie goes 5](#which-way-a-tie-goes)
- [The sign of zero 3](#the-sign-of-zero)
- [A carry that runs 3](#a-carry-that-runs)
- [A magnitude no fixed notation holds 4](#a-magnitude-no-fixed-notation-holds)
- [The place count at its edges 4](#the-place-count-at-its-edges)
- [What is refused 7](#what-is-refused)

### 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-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-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-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](#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](#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](#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](#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](#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](#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](#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](#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](#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](#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](#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-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](#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](#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](#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-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-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](#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](#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](#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](#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](#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](#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](#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-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](#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](#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-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.

- `never mutates its arguments — not applicable`

  The signature takes two `number` arguments, 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 — checked`

  Violable 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 `outputsAreEqual` is `Object.is`, which judges two infinities equal. On a numeric contract an overflow can absorb a determinism signal. This property is ordered under `no ambient input` rather 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 — checked`

  Violable 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@1` records of its own caches.
- `no ambient output — not applicable`

  Not 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 — shortened`

  Amounts with a third decimal rounded to two places, the dominant shape in pricing, tax and invoicing code.
- `half-cents — at-a-tie`

  Values 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-exact`

  Values 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 — shortened`

  Values 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 — refused`

  Calls 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.

Written for node, browser, bun.

[number · 2 contracts](../../../typescript/number/)

- [parse](../../../typescript/number/parse@1/)
- round

Domains

- [number](../../../typescript/number/)
- [date](../../../typescript/date/)
- [array](../../../typescript/array/)
- [string](../../../typescript/string/)

On this page

- [What it does](#what-it-does)
- [Try it on your own input](#try-it)
- [Reference](#reference)
- [Signature](#signature)
- [31 settled cases](#settled-cases)
- [Properties](#properties)
- [Benchmark profiles](#profiles)
- [How this contract measured](#how-this-measured)
- [What you can check yourself](#checking)
