Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Equality semantics for -0 and NaN #65

Closed
bakkot opened this issue Sep 19, 2019 · 208 comments
Closed

Equality semantics for -0 and NaN #65

bakkot opened this issue Sep 19, 2019 · 208 comments

Comments

@bakkot
Copy link
Contributor

bakkot commented Sep 19, 2019

What should each of the following evaluate to?

#[+0] == #[-0];

#[+0] === #[-0];

Object.is(#[+0], #[-0]);

#[NaN] == #[NaN];

#[NaN] === #[NaN];

Object.is(#[NaN], #[NaN]);

(For context, this is non-obvious because +0 === -0 is true, Object.is(+0, -0) is false, NaN === NaN is false, and Object.is(NaN, NaN) is true.)

Personally I lean towards the -0 cases all being false and the NaN cases all being true, so that the unusual equality semantics of -0 and NaN do not propagate to the new kinds of objects being introduced by this proposal.

@littledan
Copy link
Member

Thanks for this clean write-up! I support @bakkot's suggestion.

@bakkot
Copy link
Contributor Author

bakkot commented Sep 19, 2019

I should mention that there is one other case for which equality is non-obvious: document.all == null. But presumably document.all should not be considered to be an immutable value for the purposes of this proposal, so it couldn't be inside of a tuple or record in the first place, and the problem does not arise.

@bakkot
Copy link
Contributor Author

bakkot commented Sep 19, 2019

One more case to consider: what should

(new Set([#[+0]])).has(#[-0]);

evaluate to? My inclination is false, for the same reason as above.

(For context, this is non-obvious because (new Set([+0])).has(-0) is true.)


I guess I should mention another possible solution, which is to say that -0 cannot be in a record or tuple, either by forbidding it entirely or by normalizing it to 0. I'm not a fan of either solution, though the latter has precedent with Sets: Object.is([...(new Set([-0]))][0], -0) returns false.

@Andrewmat
Copy link

Tuples' equality should be defined as the equality of its contents. So #[NaN] === #[NaN] should be false, as NaN === NaN is false. That's because the language should not have more quirks than it already has, and confusing developing along the way (js already has too many equality comparisons)

Well, at least that was my first though, but I see some implications on this line of reasoning. Mainly, it is easy to check if something is a NaN (isNaN()) but it is not as easy to check contents of a tuple.

// worse
if (isNaN(tuple[0]) && isNaN(tuple[1]) && isNaN(tuple[2])) { }

// better
if (tuple === #[NaN, NaN, NaN]) { }

Considering that one of the aspects of this proposal improves the results of equality operations, I consider @bakkot approach the best.

@littledan
Copy link
Member

How should we decide on this question? During the discussion following the October 2019 TC39 presentation about this proposal, we heard people arguing both sides of this debate. I doubt that this is the kind of thing that we'd get useful data about by implementing various alternatives, though, as one or the other semantics here are not all that useful.

@rickbutton
Copy link
Member

Based on the discussion above (specifically the argument that we should try not to extend the unusual semantics that -0/NaN has, to more types), the champion group prefers @bakkot's suggested semantics, i.e.:

assert(#[-0] !== #[+0]);
assert(#[NaN] === #[NaN]);

I'll likely soon update the explainer with more examples to explain this, and link back to this discussion.

@Zarel
Copy link

Zarel commented Mar 20, 2020

I'm neutral to the suggested semantics for NaN, but I think enforcing #[-0] !== #[0] will lead to many subtle difficult-to-debug bugs. Currently, I would guess most JavaScript programmers don't realize that -0 and 0 are different, and the ones that do know to use Object.is when the difference is relevant.

With the -0, 0 unequivalence, most people working with them don't realize they're different. array[0] and array[-0] are the same, JSON.stringify(0) and JSON.stringify(-0) are the same, etc... as far as I'm aware, the difference only matters for 1/0 and Object.is, which aren't used by normal code. Forcing #[-0] !== #[0] would cause a lot of surprising bugs and frustration for programmers.

(This isn't a problem for the NaN equivalence, where anyone assuming #[NaN] !== #[NaN] would avoid comparing them, which would not lead to any unexpected bugs.)

I would advocate these assertions:

#[+0] == #[-0]

#[+0] === #[-0]

!Object.is(#[+0], #[-0])

#[NaN] != #[NaN]

#[NaN] !== #[NaN]

Object.is(#[NaN], #[NaN])

I'd also be happy with Set's approach of normalizing -0 to 0, in which case Object.is(#[+0], #[-0]) would be true.

(I've edited this comment because it originally advocated for a more conservative change than what I'd actually prefer, but I think it'd be better to have it accurately reflect my beliefs - please keep in mind that some emoji reacts might be for the earlier version that advocated for #[NaN] == #[NaN].)

@Zarel
Copy link

Zarel commented Mar 20, 2020

As an example, what if someone uses a record/tuple as a coordinate?

const coord = #{x: 0, y: 3};

And then they decide to move it around a bit:

const coord2 = #{x: coord.x * -4, y: coord.y - 3};
const isAtOrigin = coord2 === #{x: 0, y: 0};

isAtOrigin would be false, but I think most programmers writing code like this would assume it would be true, and if they did, they'd encounter no problems other than this specific comparison.

@papb
Copy link

papb commented Mar 21, 2020

@Zarel I think you have good points. If we're trying to reduce the chance of bugs by programmers that don't know the details of the language, probably what you suggest is the best indeed.

Also, programmers who are aware of such details, will either:

  • Memorize whatever rules end up being decided here
  • Always check on the internet these edge cases

So to be honest I think the decision is basically irrelevant for us who know these details... So why not benefit the unaware ones? :)

@devsnek
Copy link
Member

devsnek commented Mar 30, 2020

Where a value is stored shouldn't change how its identity is understood. If we are comparing records and tuples based on their contents, we should use the existing identity rules the language has for their contents.

Also... as long as we're here, IEE-754 defines -0 as equal to 0 and NaN not equal to NaN, not for lulz, but because you can end up in situations like @Zarel points out, and NaN very purposely has no identity (NaN/NaN shouldn't be 1). These are not quirks of JavaScript but important invariants of how our chosen floating point number system works.

@bakkot
Copy link
Contributor Author

bakkot commented Mar 30, 2020

If we are comparing records and tuples based on their contents, we should use the existing identity rules the language has for their contents.

JavaScript has many different identity rules, and we'd have to pick one (or rather, one for each relevant situation). The decision in this thread was to pick Object.is for all of them, as the most consistent. Other decisions are possible, but "use the existing identity rules" doesn't actually uniquely identify one possible such decision.

@devsnek
Copy link
Member

devsnek commented Mar 30, 2020

@bakkot If I do record === record i'd expect === all the way down. If i do record == record i'd expect == all the way down, and if i do Object.is(record, record) i'd expect Object.is all the way down.

@bakkot
Copy link
Contributor Author

bakkot commented Mar 30, 2020

@Zarel

const coord2 = #{x: coord.x * -4, y: coord.y - 3};
const isAtOrigin = coord2 === #{x: 0, y: 0};

isAtOrigin would be false,

This is reasonably compelling to me. I guess I would be OK with the -0 -> 0 normalization behavior that Set performs, since we have that precedent.

@Zarel
Copy link

Zarel commented Mar 31, 2020

My first choice is === all the way down.

My second is -00 normalization like Set. Incidentally, similar normalization is used by array.includes and is specced as "same-value-zero equality":

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Equality_comparisons_and_sameness#Same-value-zero_equality

(Which I think reflects the understanding of the entire rest of the spec that treating -0 and 0 as unequal is a huge footgun.)

@papb says "So to be honest I think the decision is basically irrelevant for us who know these details" but I don't think this is true – I know all the details, and I still would probably get tripped up by a #[0] !== #[-0] inequality. You would either have to be careful every time you multiplied/divided two numbers, or every time you put any number into a record/tuple, adding a lot of boilerplate code that should be unnecessary.

@devsnek
Copy link
Member

devsnek commented May 23, 2020

I see in the update slides that -0 and +0 are still being considered not equal and I want to reiterate how incorrect that is. When a negative number underflows to 0 it has to keep its sign or further values extrapolated from that number will have the incorrect sign (this is also why normalizing the value to +0 is not correct). Due to this property, IEEE 754 punts sign normalization to equality, which @Zarel helpfully demonstrated above:

As an example, what if someone uses a record/tuple as a coordinate?

const coord = #{x: 0, y: 3};

And then they decide to move it around a bit:

const coord2 = #{x: coord.x * -4, y: coord.y - 3};
const isAtOrigin = coord2 === #{x: 0, y: 0};

isAtOrigin would be false, but I think most programmers writing code like this would assume it would be true, and if they did, they'd encounter no problems other than this specific comparison.

As for NaN, it doesn't break any calculation you might be doing to make it equal to itself (although it is against IEEE 754 to do so, and some people will argue that preventing that equality can halt forward progress as NaN intends), but as I mentioned above, I think breaking programmer's expectations about recursive equality is far more harmful than the benefit of being able to compare NaN (I was unable to find a single person who thought that non-recursive equality was a good idea, and many were surprised that such a question would even need to be asked).

@icefoxen
Copy link

Just a random person here, but I would really really prefer that IEEE 754 numbers act like IEEE 754 numbers, and not what "make sense". NaN != NaN is a pain in the ass for everyone but it is there for good reasons. NaN is not a value, it's a catch-all for "can't do this". It's SUPPOSED to be a pain in the ass, because it's a signal that your code screwed up somewhere. NaN also is not a number, it's not really even intended to be a value, it's an error. If NaN == NaN, then you're saying 0/0 == inf/0 , which doesn't seem helpful at all. You might as well assert that two uninitialized values in C have to compare equally.

Second, your computer's hardware isn't going to like you trying to tell it that NaN's are equal, and there are different encodings of NaN, so it's turns every floating point comparison into multiple ones.

Please don't randomly second-guess a standard "because it seems to make sense to me", especially when it's trivial to find out the reasons these things are why they are. I'm all for trying to find a better approximation for real numbers than IEE754, for interesting values of "better", but when every computer built in the last 40 years has worked a particular way I'd like people to please think more than twice before saying "let's just randomly change the rules in this particular use case".

@littledan
Copy link
Member

Cc @erights

@erights
Copy link

erights commented May 24, 2020

new Set([+0])).has(-0) is true

I tested and this is correct. But I find it extremely surprising. But good! Where in the spec is this normalized?

Given this strange, surprising, and pleasant fact, I am leaning towards normalizing -0 to 0 with records and tuple and then adopting Object.is semantics on the result. Has most of the virtues of both Object.is and SameValueZero while avoiding most of their problems.

But first I want to understand how the spec already normalizes these for Sets and Maps. Thanks.

@erights
Copy link

erights commented May 24, 2020

Btw, the Agoric distributed object system used to carefully preserve the difference between 0 and -0 in its serialization format. We defined our distributed equality semantics for passable values to bottom out in Object.is.

We changed this to let JSON always normalize -0 to 0. Our distributed equality semantics now bottom out in SameValueZero, which works well with that normalization.

@erights
Copy link

erights commented May 24, 2020

The whole NaN !== NaN thing to me is a category error between thinking within the system of arithmetic that these values are about, vs thinking about the role these values play as distinguishable first class values in the programming language. In E there is a distinct arithmetic equality comparison operator that is a peer to <, <=, >=, and >. We call this "same magnitude as". NaN indeed is not same magnitude as NaN and -0 is same magnitude as 0. Note that the notion of magnitude that all these operators compare is about the role of these values as representative of arithmetic numbers. In JavaScript, we're stuck with == and === as the way you say "same magnitude as".

Object.is is about observable equivalence. It is about the role of these values in producing computation, and whether a difference produces observably different computation. Think about writing a functional memo function. Given a pure function of pure inputs, the memoization of that function should be observably identical to the original. This memoization has to compare current arguments against previous arguments. A pure function cannot give different results for a NaN now vs previously. A pure function can give different results for 0 and -0. The memo had better compare inputs on that basis.

@erights
Copy link

erights commented May 24, 2020

If the memo is built naively on Sets and Maps, it will work correctly on NaN but memoize incorrectly on -0.

Some other unfortunate anomalies:

['a', NaN].includes(NaN); // true, good
['a', NaN].indexOf(NaN); // -1, crazy
(_ => {
  switch(NaN) { 
    case NaN: return 'x'; 
    default: return 'y'; 
  }
})(); // y, insane. Would anyone expect that?

@erights
Copy link

erights commented May 24, 2020

SameValueZero is also surprising, but less so:

['a', -0].includes(0); // true
['a', -0, 0].indexOf(0); // 1
(_ => {
  switch(-0) { 
    case 0: return 'x';
    case -0: return 'z';
    default: return 'y'; 
  }
})(); // x

@bakkot
Copy link
Contributor Author

bakkot commented May 24, 2020

new Set([+0])).has(-0) is true

I tested and this is correct. But I find it extremely surprising. But good! Where in the spec is this normalized?

In Set.prototype.add (which is also used by the Set constructor) there is an explicit normalization which turns -0 into 0. In Set.prototype.has and Set.prototype.delete the comparison operation against items in the underlying [[SetData]] slot is performed using SameValueZero.

Map does the same thing in its set, get, and delete methods.

@erights
Copy link

erights commented May 24, 2020

Good, thanks.

I am in favor of always normalizing -0 to 0 in records and tuples. Such immutable containers would never contain a -0. We'd then compare using Object.is semantics. This has most of the benefits of both SameValueZero and of Object.is.

@devsnek
Copy link
Member

devsnek commented May 24, 2020

Did you all just skip my comment or something?

@bakkot
Copy link
Contributor Author

bakkot commented May 24, 2020

@devsnek Your comment mostly just says that you are in favor of recursive equality, meaning presumably that each of the four equality algorithms in JS would be extended so that invoking them on tuples would invoke these recursively on their contents. This would mean that, instead there being three values for which Object.is is not ===, there would now be an infinite set of them. I think that's bad, as I've already said above. There's not much else to say.

@devsnek
Copy link
Member

devsnek commented May 24, 2020

@bakkot i mean the part about zeros... sets/maps are a weird category because they deduplicate their keys. most set/map impls in languages either don't specialize for 0 and use whichever one is inserted first (like c++) or provide no default hashing implementation for doubles (like rust) but js chose to normalize it to +0. I don't think you can really take any useful conclusion for "how to store a property" from that.

Aside from maps/sets, it has been pointed out multiple times that normalizing or doing weird equality things to -0 is mathematically incorrect with regard to how ieee754 works. This crusade against having a functioning implementation of numbers needs to stop.

@Maxdamantus
Copy link

Maxdamantus commented Jul 11, 2022

Objects.equals just calls the .equals method on the object, so it's a custom equality operation. Java doesn't really have something corresponding to Object.is in JS. == in Java is used to compare values as === is in JS (with the special cases for IEEE-754 -0.0 and NaN values).

As @acutmore alluded to, arguably the important thing is the notion of equality used in other operations such as map indexing, which in Java is Object.equals:

Set.of(Double.NaN).contains(Double.NaN) // true
Object.equals(-0.0, 0.0) // false
Set.of(-0.0).contains(0.0) // false

and in JavaScript is SameValueZero:

new Set([NaN]).has(NaN) // true
-0.0 === 0.0 // true
new Set([-0.0]).has(0.0) // true

This is as opposed to eg, Haskell, which actually does maintain IEEE-754 semantics in collections:

let nan = 0.0/0.0
Set.member nan (Set.fromList [nan]) -- False
Set.member (-0.0) (Set.fromList [0.0]) -- True
Set.fromList [nan, nan] -- fromList [NaN,NaN]
Set.fromList [0.0, -0.0] -- fromList [-0.0]
Set.fromList [-0.0, 0.0] -- fromList [0.0]

I actually prefer the Haskell semantics for consistency, even though it does have some potentially surprising cases above, though Map/Set don't work this way in JS or Java.

@hax
Copy link
Member

hax commented Jul 11, 2022

@Maxdamantus Java and JS use different name, but JS === is like Java == and JS Object.is like Java Objects.equals. Of coz they have differences, because JS do not have class defined equals behavior.

And JS do not use === for existence check, use samevaluezero. If follow Java, it should use Object.is. If follow haskell, it should use === (if i understand correctly).

Programming languages have differences on existence check, it may be ok, because they could have different definition for "key" and "existence", but I think programmers would like to have the consistent == (in JS ===) definition across the languages.

@Maxdamantus
Copy link

Maxdamantus commented Jul 11, 2022

@Maxdamantus Java and JS use different name, but JS === is like Java == and JS Object.is like Java Objects.equals. Of coz they have differences, because JS do not have class defined equals behavior.

JavaScript's Object.is and Java's Objects.equals are only alike in that they compare doubles (actually, Double objects) by content. In other cases they are usually different, since for collections they also tend to compare content:

var a = new ArrayList<Integer>();
var b = new ArrayList<Integer>();
Objects.equals(a, b); // true
a.add(42);
Objects.equals(a, b); // false

In JavaScript, Object.is says whether its operands are the same value, which is not what's happening above (a and b are different reference values, because operating on one is not the same as operating on the other).

And JS do not use === for existence check, use samevaluezero. If follow Java, it should use Object.is. (Note, you have a type, that new Set([-0.0]).has(0.0) give u true). If follow haskell, it should use === (if i understand correctly).

Sorry, I messed that example up. I guess the point might be that there's already an inconsistency in JS, so it's not clear that one way is better than the other, which is probably why I don't feel strongly on one side or the other. As I was saying, personally I think it would have been cleaner if they had made Set and Map use === for comparison, like Haskell does (though I don't know if this would have caused other issues in the language).

@hax
Copy link
Member

hax commented Jul 11, 2022

@Maxdamantus Java ArrayList.equals works just because ArrayList override equals, if not override, it give false. This is what I mean "because JS do not have class defined equals behavior."

I guess the point might be that there's already an inconsistency in JS

The "inconsistency" we are talking is JS actually have some basic "consistency", that is using SameValueZero for existence check, so I would like we can keep that "consistency" of "inconsistency", not introduce another level inconsistency to ===.

@hax
Copy link
Member

hax commented Jul 11, 2022

Another question, [...new Set([-0])][0] give u +0 (-0 is converted to +0), what's the result of [...new Set([#[-0]])][0][0] ? +0 or -0? I suppose it's -0?

@rickbutton
Copy link
Member

rickbutton commented Jul 11, 2022

@hax https://tc39.es/ecma262/#sec-set.prototype.add

Set.prototype.add always converts -0 to +0, the Set constructor uses Set.prototype.add. Both cases will be +0.

@nicolo-ribaudo
Copy link
Member

nicolo-ribaudo commented Jul 11, 2022

@rickbutton I think you missed some parentheses: the second case is -0, because it's calling .add(#[-0]) and thus it's not doing any conversion.

@rickbutton
Copy link
Member

Good point, ignore me.

@papb
Copy link

papb commented Aug 9, 2022

I was wondering: was the option of raising an error upon an attempt of putting NaN into a record/tuple considered?

I see that @rricard's comment says:

Record & Tuple being primitives, they should be able to contain any other primitive value including -0 and NaN

...but I was hoping for more details. Why does "being a primitive" imply "being able to contain any other primitive"? As I see, Record & Tuple are the first kind of primitives that contain multiple values, so no precedent seems to exist for this...

@ljharb
Copy link
Member

ljharb commented Aug 9, 2022

I'd put it another way: NaN and -0 are primitives, and shouldn't be "special" by being specifically excluded. Doing so would be very confusing and weird.

@papb
Copy link

papb commented Aug 9, 2022

I was talking specifically about NaN, in the sense that it basically represents an error state (as said by @icefoxen), so this might be an opportunity to turn it into a real error.

By specifying that Records and Tuples with NaN can't even exist, we no longer have to deal with the paradoxical behavior it brings when treated as a value (namely, that it makes equality non-reflexive).

  • Case 1: tuple being used as a point

    const point1 = #[0, Math.sqrt(-3)]
    const point2 = #[0, Math.sqrt(-2)]
    point1 === point2 // should be false, for the same reason that Math.sqrt(-3) !== Math.sqrt(-2)
  • Case 2: large record with lots of nested records

    • It would be weird to have this large record of data be not equal to itself because one of the 1000s of leaves is the NaN value.

Whichever decision is made for NaN, one of the cases above would not be pleased. So what about the option of making these situations impossible?

@acutmore
Copy link
Collaborator

acutmore commented Aug 9, 2022

Hi @papb thanks for the idea, I think you’re right in that that rejecting NaN may not have been considered before.

I do think that @rricard ’s comment you quoted is a strong design goal for the proposal. “[R&T] should be able to contain any other primitive value”. While they do reject object values due to the help catch accidental introduction of mutability or identity or both. Rejecting NaN because it might 'represent an error' sounds outside the responsibility of the container.

@acutmore
Copy link
Collaborator

acutmore commented Aug 9, 2022

As I see, Record & Tuple are the first kind of primitives that contain multiple values, so no precedent seems to exist for this...

While that may be true. There is already precedent in the language for how equality of NaN behaves within a container. [NaN].includes(NaN) === true

@erights
Copy link

erights commented Aug 9, 2022

Normally, the objection "but that would be weird" (however phrased) is properly taken to be a strong objection, because it indicates the feature would violate the principle of least surprise. For -0 and NaN within Records and Tuples, all of the choices anyone has yet invented are weird in this sense. The objection is not strong if it also applies to all alternatives.

@ljharb
Copy link
Member

ljharb commented Aug 9, 2022

@erights while i agree with that, rejecting NaN from a container is much, much weirder than giving a surprising equality answer.

@erights
Copy link

erights commented Aug 9, 2022

Hi @ljharb I do not disagree. Nevertheless, I felt it worth making the meta point. Thanks.

@pygy
Copy link

pygy commented Aug 10, 2022

Both are weird, but rejecting them at construction time has the advantage of making the surprising behavior obvious, while having unusual equality semantics can lead to subtle bugs.

@erights
Copy link

erights commented Aug 10, 2022

@pygy that's a good point. The principle of least surprise must be understood in terms of the dangerous consequences of a surprise:

  • Static rejection --- least damaging. Happens during development. Even if not understood, it is "corrected" during development by trying other things until something not statically rejected is found
  • Reliable runtime error thrown --- Happens during development if that case is covered. Even if missed during development, at runtime a surprising throw usually (not always) causes a fast failure that threatens availability but not integrity.
  • Silently does something unexpected --- typically without visible symptoms. Usually seems to have worked correctly. The worst kind of surprise. Afterwards, code continues to follow normal (non-exceptional) control-flow paths whose assumptions are now violated. Corrupting state and endangering integrity.

On these grounds, a dynamic early rejection of placing problematic values into R&T is clearly less dangerous than having a weird equality semantics, where the surprise case still returns a boolean rather than throwing.

Despite this, both @ljharb and I agree above to take the hit on the weird equality semantics (silent divergence) rather than the dynamic early weird value rejection (reliable throw). It is still an overall tradeoff, but I appreciate the logic of this counter-argument. We should still take it seriously, even if we feel that other factors overrule it in this case.

@papb
Copy link

papb commented Aug 11, 2022

@erights That's a superb way of putting it! Thank you very much!

Despite this, both @ljharb and I agree above to take the hit on the weird equality semantics (silent divergence) rather than the dynamic early weird value rejection (reliable throw).

Can you please clarify why?

even if we feel that other factors overrule it in this case

What factors?

Note: I know that there are already multiple arguments within this long issue, but... To me, everything you said in the last comment is a very strong argument in favor of opting for the early throw... I would love to see how exactly you're still capable of disagreeing with such strong argument (written by yourself 😅)

@acusti
Copy link
Contributor

acusti commented Aug 11, 2022

@papb i can’t speak for anyone else, but i can speak for myself: as a JS developer, i would be truly shocked and perplexed if creating a tuple or record with NaN as a value in it threw an error. for those who assiduously treat NaN as an error state (by convention, because the language doesn’t enforce that), this behavior would most likely be harmless. but for the majority of JS developers, who are not able to enforce such strict avoidance of NaN except when their code is in an error state, it would be a bad surprise. consider:

const savedItemsPerRow = parseInt(localStorage.getItem('itemsPerRow'), 10);
updateStore('userPreferences', #{ itemsPerRow: savedItemsPerRow });
// elsewhere (getItemsPerRow gets userPreferences from the store, then itemsPerRow from that record):
const itemsPerRow = getItemsPerRow(store) || 3;

if records allow NaN, this will work fine even if there is no itemsPerRow key set in localStorage, or if it is set but it can’t be parsed as an int, because NaN is a falsey value, so getItemsPerRow(store) || 3 will return 3 as expected. however, if creating a record with NaN as a value will throw, this code will break in production (though not in dev if the person testing has a value set in localStorage that can be parsed as an int). i show the case of a record here, but obviously it applies equally to tuples.

@pygy
Copy link

pygy commented Aug 11, 2022

The surprises for NaN and -0 fundamentally lie in the IEEE-754 spec, and the additional complexity added by how JS handles them (e.g. String(-0) === '0').

How surprising these are depends on how acquainted coders are with those intricacies.

My guess would be that the median dev is not at all familiar with any of this (e.g. I just discovered that the default JSON stringifier culls the minus sign of -0, whereas JSON.parse respects the minus sign), and diking the values out of new parts of the language would be a net improvement for code reliability.

@waldemarhorwat
Copy link

There are many contexts in which you want to store data without any need for comparison semantics — in common programming language value categories (example: C++ concepts), storable categories are a large superset of comparable categories.

Disallowing NaNs would be far more damaging than treating records containing them as equal. It would gratuitously break the use case of using records to temporarily store some Numbers and retrieve them later without any interpretation of what Numbers they contain.

@sjrd
Copy link

sjrd commented Aug 11, 2022

I wish we could generalize that reasoning not to stop at Numbers, but go all the way to any Values. Unfortunately, that's broken because we can't store and retrieve uninterpreted objects. 😞

@papb
Copy link

papb commented Aug 16, 2022

Yeah, I think I'm convinced that throwing on NaN would do more harm than good. Thank you everyone for your inputs.

Maybe NaN itself should not exist as a value. Maybe 1/0 should just throw since the beginning. But it's too late for this, obviously. The reality is that NaN is a value that can be stored in variables, passed around, and all that. Disallowing it in only in records and tuples would prevent things like what @waldemarhorwat said:

It would gratuitously break the use case of using records to temporarily store some Numbers and retrieve them later without any interpretation of what Numbers they contain.

Something else that crossed my mind: it would also become a hassle for people to refactor existing code that uses objects and arrays into records and tuples. The special NaN behavior would be something else to keep in mind (apart from mutability).

I think it might be even possible for TypeScript projects to have automated refactoring to records and tuples, by statically analyzing whether or not each array/object has to be mutable. If NaN was forbidden, this would become impossible...

@Maxdamantus
Copy link

Maxdamantus commented Aug 16, 2022

#65 (comment)

@rickbutton I think you missed some parentheses: the second case is -0, because it's calling .add(#[-0]) and thus it's not doing any conversion.

Is this intentional, or has this simply not been addressed yet? I'd expect that Set and Map should either use SameValueZero or normalise -0 to 0 when dealing with R/T keys (the former would do a better job at preserving sign, but the latter would be consistent with the existing normalisation):

Currently specified behaviour according to comment:

const a = -10;
const set = new Set();
set.add(#[a*0, a*0]);
set.has(#[0, 0]); // false, because it has `#[-0, -0]` instead; EDIT: nevermind; it will be true

In general, 0 and -0 should be interchangable (only distinguished by Object.is or during some division-by-zero operation; even (-0).toString() and (0).toString() do the same thing).

Edit: nevermind. It already uses SameValueZero, so it essentially uses the former approach (which might be surprising, but imo would have been the better behaviour even for non-R/T -0 and 0 values).

@acutmore
Copy link
Collaborator

@Maxdamantus Map and Set will continue to apply SameValueZero on Records & Tuples with no conversion on insertion.

const s = new Set();
s.add(#[-0]).add(#[0]);
s.size; // 1
Object.is(-0, […s].at(0).at(0)); // true

@pygy
Copy link

pygy commented Aug 27, 2022

@papb re. typing IEEE Floats are effectively a Maybe number (number | NaN in a parallel world where TS would have typed them as such) type, with NaN as the Nothing type.

TS could keep track of the operations that can return NaN, make math ops generic, and have them return number | NaN unless they can statically prove that the result isn't NaN.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests