tc39 / proposal-decimal

Built-in exact decimal numbers for JavaScript
http://tc39.es/proposal-decimal/
479 stars 17 forks source link

Completely remove NaNs, infinities, signed zeroes and quantums. Remember their original motivation #172

Open safinaskar opened 1 month ago

safinaskar commented 1 month ago

Current version of the spec includes NaNs, infinities and signed zeroes. So, we would like to ask a question: why they was introduced originally? Here is the answer.

First, they was designed for calculations, which often appear in physics, engineering and calculus, for example ( https://en.wikipedia.org/wiki/IEEE_754 ):

Moreover, the choices of special values returned in exceptional cases were designed to give the correct answer in many cases. For instance, under IEEE 754 arithmetic, continued fractions such as R(z) := 7 − 3/[z − 2 − 1/(z − 7 + 10/[z − 2 − 2/(z − 3)])] will give the correct answer on all inputs, as the potential divide by zero, e.g. for z = 3, is correctly handled by giving +infinity, and so such exceptions can be safely ignored.

In other words, if you calculate 1 / (1 / 0), then you will get 0, and this is what you likely expected (at least in physics).

Second, they are designed for programming languages without exceptions, so "always produce some answer" was a requirement.

Neither of these reasons applies to our proposal. Decimal type is designed not for calculating engineering formulas such as above (there are normal binary floating point numbers for this), but for calculating money. And JavaScript does have exceptions.

So, I propose full removal of NaNs, infinities and signed zeroes.

You may say "but IEEE 754 Decimal128 has NaNs, etc". Yes, it does. This is just cargo cult. It seems they just copied everything from binary floating point numbers without thinking. This is just an error. So, let's fix this error! This will not create significant burden for implementors. They just will throw exception if they get NaNs or infinities.

Also, I propose removal of quantums. I don't know what is their original motivation, but I suppose their motivation was engineering, too.

Let's assume I measured length of pencil and said "it's length is 10.00 cm". What does 10.00 mean? Why there are extra zeroes here? "10.00 cm" means that length of pencil is between 9.995 cm and 10.005 cm (see https://en.wikipedia.org/wiki/Significant_figures ). I. e. extra zeroes here convey information about precision. But, again, all these applies to physics and engineering. All these doesn't make any sense for money. There is no difference between 10.00 usd and 10.000 usd. Both numbers mean exactly 10 usd with infinite precision.

So my proposal is: completely remove quantums. I. e. make them absolutely unobservable (it is okay if implementation uses them internally if they are not exposed).

You may say: "but what if user wants to convert monetary number to string with extra zeroes? What if he wants to get string "10.00 usd"?" This question is completely unrelated to quantums in their original form. Again: quantums are needed for tracking precision/accuracy as result of physical measurements. They have nothing to do with printing strings like "10.00 usd" on a screen. If you want to get string "10.00", just create custom formatting function. But don't carry quantum with number.

You may say: "But other libraries for decimal numbers have quantums, for example rust_decimal ( https://crates.io/crates/rust_decimal )". Yes, it does. And this is error. They copied well-known engineering convention of significant figures to their library without thinking.

Now useful links:

boronine commented 1 month ago

For money calculations, these NaN, Infinity (etc.) can cause really bad bugs:

I would much rather get a DivideByZero exception than an Inifinity value if I was trying to write safe money math in JS.

julienetie commented 3 weeks ago

For money calculations, these NaN, Infinity (etc.) can cause really bad bugs:

  • Does not throw exception, so undetectable in your error logs in production
  • Propagates unexpected values all over your code in production

I would much rather get a DivideByZero exception than an Inifinity value if I was trying to write safe money math in JS.

The problem is not NaN, Infinity etc the problem is lack of tools to manage them. If you try to get too clever and eliminate NaN and such from results, you will create a situation where JavaScript becomes more unpredictable and frustrating than expected.

You don't need exceptions. You need functions that pass-through unless ?.

// I don't know the spec well so take this with a pinch of salt. This is how I imagine it would be handled 
const decimal =   /* do stuff */ new Decimal128(...) /* do stuff */
const value = Number(decimal)  // correct me if I'm wrong.  
const result = Number.isNaN(value) ? 0 : value 

// It would be better to have a fns similar-ish to these
const value = Number.ifNaN( Decimal128(...), 0)
const value = Number.ifSignedZero( Decimal128(...), 0)
const value = Number.ifInfinity( Decimal128(...), null)

// or something like
const value = Number.safe(["NaN", "signed-zero", "infinity"],  Decimal128(...)) // Default desirable behavior is managed as floats

const value = Number.safe(["NaN", "signed-zero", "infinity"],  Decimal128(...), [0,0,false]) // Custom behaviors and we can have 
// More types of undesirable number outcomes, probably throw in a callback for better management. 

My argument is really that It's important to have some kind of solution decoupled from "Decimal" as a float and that all those undesirable values are necessary.

boronine commented 2 weeks ago

@julienetie how do your examples address the type of bugs I mentioned in my comments? The problem with NaN is not lack of tools, it's the fact that it's a silent failure BY DESIGN.

I've never encountered a legitimate use case for isNaN other than for wrapping other people's broken code. In production code, you should instead check if denominator === 0 (which doesn't require any helper functions). The bugs appear when you FORGET to check. And same bugs will appear when you forget to use your proposed helper functions.

EDIT: divide by zero returns Infinity, not NaN, but my point stands

safinaskar commented 2 weeks ago

@julienetie, your arguments are probably good for binary floating numbers, but not for decimal floating numbers. Decimal floating numbers are for money, and I don't see a single use case for infinities and NaNs.

@boronine

divide by zero returns Infinity, not NaN, but my point stands

1/0 is infinity and 0/0 is NaN. At least this is true for usual IEEE 754 binary floating point, I don't know whether this applies to IEEE 754 decimal floating point (and, alas, actual IEEE 754 standard is behind a paywall)

julienetie commented 2 weeks ago

how do your examples address the type of bugs I mentioned in my comments? The problem with NaN is not lack of tools, it's the fact that it's a silent failure BY DESIGN.

your arguments are probably good for binary floating numbers, but not for decimal floating numbers. Decimal floating numbers are for money, and I don't see a single use case for infinities and NaNs.

@boronine NaN only appears to be silent because JS has no number safety methods. There is no safe way to handle undesirable number results in JavaScript without custom helpers or boilerplate code.

@safinaskar @boronine All those values are useful for conditionally deciding what type of outcome you want. Errors don't do anything for reducing boilerplate code when it comes to numbers, and devs are often unaware you have to manage them. E.g.

try {
  new Decimal128(value1).divide(value2);
} catch(e) {
  // Uncaught RangeError: Division by zero
}

Hopefully this makes it easier to understand why JS should be more reliant on methods/ operators oriented around safe numbers than number errors. (Consider how modern languages use operators to treat null, nil and undefined values)

But I do understand your stance, the above is also the behavior of BigInt, so Decimal() should probably keep consistent with BigInt behavior. But there is a bit of a difference in usage.

BigInt is not intended as a final result. We can't even log 10n. Decimal is a format so it is expected as the final result. But if you use Decimal as the final result you have to account for various errors. By the time you've cover all potential errors you will be looking at a dozen or so lines of code and slowly realise that all of this could have been solved easier with the standard expected types and values in conjunction with some sort of safety fn.

I've never encountered a legitimate use case for isNaN other than for wrapping other people's broken code

@boronine Number.isNaN is paramount for numbers in JS especially if you're not using isNumber, isInfinity or Object.is(NaN, value) to some capacity.

E.g. If you have to output Shopping Cart Total: $${total} ideally it should go though a NaN check and provide a fallback value or action in place of undesirable results.

Assuming your total derives from Decimal(), if any part of your algorithm is sourced from a let or var number type it will usually require a NaN check and an Infinity check if arbitrary or from a 3rd party.

boronine commented 2 weeks ago

@julienetie you still have not addressed my concern. DivideByZero exception is safer in production than your proposed solutions because it fails fast, logs the error and provides a stack trace. Just to reiterate: my concern isn't about how convenient it is to handle unsafe operations, it's what happens when you inevitably FORGET to handle them.

Number.isNaN is paramount for numbers in JS ... E.g. If you have to output Shopping Cart Total: $${total} ideally it should go though a NaN check ...

But what if you FORGET to do a NaN check? How do your helper functions prevent silent failure?

Errors don't do anything for reducing boilerplate code when it comes to numbers

Exceptions like DivideByZero rarely need to be caught and handled, again, the solution is to check if denominator === 0. The utility of the exceptions is to notify the developer of the bug so they can add this check.

Assuming your total derives from Decimal(), if any part of your algorithm is sourced from a let or var number type it will usually require a NaN check and an Infinity check if arbitrary or from a 3rd party.

The solution is for Decimal constructor to throw an exception when instantiated with NaN or Infinity.

julienetie commented 2 weeks ago

you still have not addressed my concern. DivideByZero exception is safer in production than your proposed solutions because it fails fast,

@boronine Of course I addressed it. You would have to wrap every new Decimal128() in try/ catch. It's not safer, there is no guarantee everyone will use try-catch especially if the risk is not apparent.

Are you understanding my concern? In real-world apps, you have the problem of devs using Decimal128() to format a final value forgetting the risk of e.g. DivideByZero if everything works fine at a glance. But in production with different values user can get an error that halts execution.

Try/ catch should not be the solution for undesirable numbers on the web platform. Modern systems languages are mitigating various number errors common in C, why exactly are we increasing them?

But what if you FORGET to do a NaN check? How do your helper functions prevent silent failure?

If you forget then the user gets NaN. NaN is bad, but complete breakage is 10x worse. This is why I suggest we should push for safety number as a holistic solution, but that's another conversation.

Exceptions like DivideByZero rarely need to be caught and handled

I strongly disagree.

In production code, you should instead check if denominator === 0

Same applies, but what if you forget?

boronine commented 2 weeks ago

Of course I addressed it. You would have to wrap every new Decimal128() in try/ catch.

Why? If you're worried of receiving a NaN in a decimal constructor, you're worried about a bug in your code. How would try/catch help with that? The solution is precisely NOT to handle it and let the exception notify you of the bug. This is a prime use case of exceptions and why they come with stack traces.

Modern systems languages are mitigating various number errors common in C, why exactly are we increasing them?

Every language that I've ever used halts execution when dividing integers by zero precisely because it's the safer thing to do - JS bigint, Python, Java, Rust, Zig etc. Can you give a counterexample?

If you forget then the user gets NaN. NaN is bad, but complete breakage is 10x worse.

By the time you get a NaN, you can be sure that your code has a bug in it. Do you prefer the bug to be unnoticed for weeks until someone complains? Or worse, propagate incorrect values into unexpected places causing who knows what other silent breakage? Keep in mind that the prime use case for decimals is MONEY.

Exceptions like DivideByZero rarely need to be caught and handled

I strongly disagree.

In production code, you should instead check if denominator === 0

Same applies, but what if you forget?

If you forget, you get an exception that notifies you of the bug and even tells you exactly where it is by means of stack trace. You DON'T need to handle the exception, you just need to FIX THE BUG that caused it in the first place.