tc39/proposal-optional-chaining

(null)?.b should evaluate to null, not undefined

claudepache opened this issue · 77 comments

In other words, the desugaring of a?.b should be a == null ? a : a.b (instead of a == null ? undefined : a.b as currently specced).

I gave the following justification for having a?.b === undefined when a === null in README#FAQ (emphasis added on the contended part):

Neither a.b nor a?.b is intended to preserve arbitrary information on the base object a, but only to give information about the property "b" of that object. If a property "b" is absent from a, this is reflected by a.b === undefined and a?.b === undefined.

In particular, the value null is considered to have no properties; therefore, (null)?.b is undefined.

However, that justification is flawed, because of the short-circuiting semantics: when a is null, the name of the eventual property (here, "b") is not sought, so that there is no property to give information about.

This is clearer when the property name is computed, as in, e.g.:

a?.[++i]
a?.[foo(a)] // where `foo(a)` throws a TypeError exception when `a` is null

In case of a?.[foo(a)], if a is null, we do not compute the value of foo(a) (and we couldn’t, if foo(null) throws a TypeError), so that it doesn’t make sense to try to ”give information about the property foo(a) of a”.


I think it is more sensible to apply the usual semantics of short-circuiting operators (&& and ||), at least for consistency reason, namely: In case of short-circuiting, evaluate to the last encountered value.

Incidentally, that would avoid to clear the null/undefined distinction of the base object, see #65.

While a good edge-case to recognize, I think the counterpoint would be that you can actually give information about b in a.b if a is nullish, because you know for absolute certain that a.b does not exist, no matter the property name. Or to take a real world example, if you ask me "What color is the car?" (or ask me about any property of the car) when there's no car, I can confidently respond "That's undefined", and it would be the most direct answer to your question.

I could also respond "There is no car" (the equivalent of returning null, as I proposed in #65), but it assumes some intent behind the question (that questioning assumptions is more useful and just answering) rather than precisely answering it. Personally, I thought that would be the more useful answer most of the time (hence filing #65), but ultimately I recognize that it's less about which is more "correct" and more a subjective choice between which of those two valid behaviors ?. is intended to perform. At least it seems the community overwhelmingly expects the more direct answer ("The color is undefined") than the more intent-based answer ("There is no car").

lehni commented

@0x24a537r9 adding to what you said, once the property is more than one level nested, e.g. in the question "what is the color of the car's seats", the answer null isn't clear anymore: It could be that there is no car, or the car is indeed there, but doesn't have any seats. That is why I believe undefined is the better answer here.

Eh, using undefined suffers the same issue. If you ask "What color are the car seats?" and receive undefined, it's equally unclear whether that's because there's no car or the car has no seats.

Both approaches lose information and can be ambiguous--it's just a choice of which cases you want to prioritize.

hax commented

Agree with @lehni .

As we know, in many cases undefined in JS is playing the role of type checking errors in static type languages. So it's a good mappings that null.b throw error and null?.b returns undefined.

Further, most APIs may have type T | null (but rarely T | undefined) which null could convey special meaning. If a.b returns type T | null, a?.b should be T | null | undefined. Assume a?.b returns null, as current semantic, we would know it come from a.b and convey the special meaning of such API. But if we change the semantic that null?.x returns null, we will never tell whether it come from a.b or a. These two null may convey very different meaning.

I think if you need that certainty, you’d be unable to use this operator regardless, so that’s not an applicable argument.

I think @hax nailed it. It is a matter of clarity and correctness. Shoving null through an optional chain is quite confusing. The simple fact of the matter is, null.b is undefined. It doesn't matter how we end up with a null there, doesn't matter how long the chain is, null is nothing and has no properties, so any attempt to optionally access a property on a null should result in undefined.

I think it is more sensible to apply the usual semantics of short-circuiting operators (&& and ||), at least for consistency reason, namely: In case of short-circuiting, evaluate to the last encountered value.

I don't think this is an apt comparison—short-circuiting is based on a falsy check, not a nullish check. 🤔

Note that in o.get('a.b.c')-style stop-gap APIs for this feature, the result is uncontroversially undefined if we can't reach c. I know the syntax differs, but the intention is the same: it's about "reachability", i.e., either I get the value of c, or I fail to do so.

We need another operator which returns the last property in the chain which is not undefined.

We definitely don't need another operator - also ?(a.b.c) won't work because that could be the positive branch in a ternary expression.

@rkirsling It is an apt comparison for ?? which is short-circuiting and based on a nullish check.

Also, the result of o.get('a.b.c') is not uncontroversially undefined. See idx().

That said, I think, unfortunately, both sides are talking past each other because they're not able to see that the other group is expecting fundamentally different behavior, and neither is inherently wrong--just different expectations. One group defines ?. to be "about" getting the property. The other group disagrees, defining ?. to be "about" both the property and the chain of access along the way (like a ?? a.b ?? a.c). One could conceivably have them be two separate operators (though that would be inadvisable due to confusion).

@rkirsling and @lehni, I'm not saying you're wrong, but as someone who was at one point on the other side of this interpretation (and now on the side of undefined), I can tell you that insisting that returning null is inconsistent with your definition of ?. is irrelevant to someone who disagrees with your definition. It is likely to be about as convincing as telling a Buddhist that they should act a certain way because the Bible says so--your fundamental expectations of what you are trying to do are different, so you can't apply the rules of one approach to another.

Those that believe that null?.a should return null may already understand that a is undefined under the expectation of how you are defining ?. but still persist in their beliefs because they fundamentally disagree with your premise that ?. should be just "about" a. They expect that ?. is closer to chained ?? operators, and that's a question of interpretation/expectation, not correctness. The issue at hand is which definition we should use for ?. (hence my survey in #65), and to advocate for one saying that the other doesn't cohere to the former's definition is circular--you can't say apples are better than oranges because oranges don't taste like apples.

@0x24a537r9

Also, the result of o.get('a.b.c') is not uncontroversially undefined. See idx().

D'oh, I guess that was wishful thinking then. (The specific cases I had in mind were Lodash and Ember.)

I think this operator is very desirable either way, so I'm sorry if my comment just added speed to wheels that are already spinning. Just hoped to provide a pointed statement of how one could conceptualize it.

hax commented

Also, the result of o.get('a.b.c') is not uncontroversially undefined. See idx().

@0x24a537r9 I read #65 again, but still can not get why idx() choose the other way. Could you give some concrete use cases?

@hax As alluded to in the Usage section of the Github, it was intended to be a drop-in replacement for the existing pattern of a && a.b && a.b.c (while also being slightly, but usually inconsequentially, more rigorous by using nullish comparison rather than falsey). From that perspective, idx() and others like it are intended to return "the first non-nullish value in the chain", rather than "the possibly undefined value of the last item in the chain".

One advantage of this scheme is that if you ever receive undefined and the last property is required, you often have good signal that there's a bug and you're trying to look up a nonexistent field, not just running into an optional object along the way (null). In the proposed scheme for ?. you can't be sure whether you accidentally took an invalid codepath (and thus tried to look up an invalid field) or some object along the chain was simply missing as expected. Of course, there are tradeoffs on both sides.

hax commented

@0x24a537r9 Still no concrete use case 🧐 ...

But I will try my best to understand your motivation according to your last comment. Please forgive me and correct me if I misread your comment.

One advantage of this scheme is that if you ever receive undefined and the last property is required, you often have good signal that there's a bug and you're trying to look up a nonexistent field, not just running into an optional object along the way (null).

Interesting idea, it seems you only want to deal with null but NOT undefined. The most interesting part is such idea is also based on "undefined is used to denote error" convention I mentioned before.

But the main advantage of ?. is ergonomic, so I can't imagine programmers will be happy to write extra guard code to differentiate undefined like:

const x = a?.b.c
if (x === undefined) {
   // something maybe wrong, but what can we do here?
}

And if you don't check the undefined, then you just delay the potential reference error to future calling/accessing, which make the bug much harder to find and debug.

The only possible "correct" solution I can imagine for this direction is:

Just throw Error instead of return undefined. Aka, make null?.x returns null and make undefined?.x throws (just like undefined.x throws). This is even much practical for TS/flow because they can avoid undefined?.x in first place (assume intentional usage of undefined is very uncommon in APIs).

The main problem of such semantic is: JS is not TS/Flow ... Especially for the guys who don't like static typings, they will very likely "abuse" ?. to eliminate all potential null/undefined errors. Aka, "good signal" is meaningless to them. So I'm afraid let undefined throws will not satisfy them.

In the proposed scheme for ?. you can't be sure whether you accidentally took an invalid codepath (and thus tried to look up an invalid field) or some object along the chain was simply missing as expected.

As my analysis before, null?.x returns null scheme can not practically solve the invalid codepath issue. So the counterargument is, if you really care about invalid codepath / field access, you should use TS/Flow anyway.

Of course, there are tradeoffs on both sides.

True. I believe the key point is if such tradeoff is practical.

This change matches my intuition, and I seem to not be the only one. I support it.

A lot of comments here and in other threads relate to what other libraries do. I believe there are several of these. Would anyone be interested in preparing a survey of the semantics of these libraries? It could be an interesting data point for comparison.

The expectation of the “correct” result for (null)?.b highly depends on your mental model:

(A) a?.b is interpreted as ”the same as a.b, except that it does not throw for null/undefined”. For that interpretation, (null)?.b === undefined.

That interpretation is problematic in the face of short-circuiting (an objectively desirable feature) in expressions like a?.b.c. One could resolve it with the help of artefacts, such as using an adhoc Nil reference instead of a plain undefined value (that is how it was historically specced before #20).

(B) a?.b is interpreted as ”the same as a && a.b, except that we test for nullish-ity rather that falsy-ness”. For that interpretation, (null)?.b === null. (That interpretation matches more closely the current spec since #20.)

(After having written that comment, I realise that someone has already said that.)


But besides mental model, there are certainly some situations where it would be more useful (and sound) to have (null)?.b === null (concrete example in the next comment), and other situations where undefined is better. Given that undefined exhibits, more often than null, some special behaviour (either in builtin features like defaults, or in libraries), I think that it is safer to avoid producing undefined from null.

As the majority seems to think that (null)?.b === undefined is better, here is a fictional example where it is not the case:

Consider the following function. At one point in time, black paint was the evident choice for cars:

function purchasePaintForCar() {
    // go to store and buy black paint
}

Later, more dyes became available, so the function was refactored:

function purchasePaintForCar(color = 'black') {
    // go to store and buy a paint of that colour
}

Now consider an object modelling a car, with a field color that contains the desired colour:

let color = myCar.color // "red"
purchasePaintForCar(color)  // buy red paint

(As an aside, that will work even for legacy car objects that doesn’t have the color field; so that purchasePaintForCar(myFordT.color /* undefined */) will purchase paint of correct dye. But that’s not my point.)

Now, there is a provision for using null in case the colour is not yet known, or even in case that, because of technological progress, there exist cars that do not need to be painted at all:

let color = myFutureCar.color // null
purchasePaintForCar(color)  // do nothing (don't buy paint)

Or even it is not certain that I will have a car in the future:

let myFutureCar = null 
let color = myFutureCar?.color // null
purchasePaintForCar(color)  // don't buy paint, even not black paint

@claudepache Now imagine that the car data is coming from a server, and we call one of the following:

purchasePaintForCar(response.data.defaultCar?.cosmeticOptions?.color);
// OR
purchasePaintForCar(response.data.cars[0]?.cosmeticOptions?.color);

In the empty case, defaultCar is most likely null, and cars is of course [].

Under your approach, these exhibit different behavior. Whether this is "correct" or not isn't too important (e.g., we could imagine a function updateCarUIColor(color = 'black') that doesn't expect null)—it's a question of least surprise. I would be hard-pressed to fault developers for forgetting that these won't be the same, as the distinction would be an unnecessary thought burden...until suddenly, it's not. Seems like a guaranteed "dammit!" moment to me. 😄

Why would defaultCar be null? In a server implementation I’d write, I’d probably omit the value entirely rather than inflate the bandwidth size just to match a strictly typed shape.

@ljharb Because it's a server you don't control, I guess. 😛
That response shape is inspired by a true story, but what I'm trying to convey is simply that imperfect JSON APIs are ubiquitous and wading through their data is the selling point of ?. for many people.

I don't think either is clearly better than the other.

always undefined:

  • Pros
    • Lodash does this
  • Cons
    • It'll surprise some people
    • It can't be easily normalized to null

maybe null or undefined:

  • Pros
    • It can be easily normalized with ??: maybeNull?.prop ?? undefined
  • Cons
    • It'll surprise some people
    • Lodash doesn't do this

Given two choices where one makes the other impossible, and the other makes both possible, we should probably prefer the latter?

we should probably prefer the latter?

Personally, I lean that way. But, _.get is entrenched with some 1,662,292 matches in public Github. There will be bugs.

Is there a another language which has

  • a null/none/nil object
  • something analogous to a read-only undefined that you can assign (for example the del statement in Python wouldn't qualify)
  • the ? operator

?

lehni commented

@jridgewell regarding normalisation:

In the first scenario, why can't it be normalized to null the same way as to undefined in the 2nd?

maybeNull?.prop ?? null

And regarding the 2nd scenario:

maybeNull?.prop ?? undefined

What if I'd like to distinguish the case where prop is null vs prop is not set (undefined)?

I think with such normalization, there is always a loss of information, in both cases. I don't see one being better necessarily than the other, just good for different kinds of situations.

Regarding the second, what if I want to know if a value exists, but is
null? Then we're needlessly complicating what should be a simple query.

I don't understand. You don't need to normalize.

In the first scenario, why can't it be normalized to null the same way as to undefined in the 2nd?

Because you can't tell if it was really null or undefined to begin with. So there's no way to "get the null out".

However, only cared if it was undefined (which is what the people arguing for (null)?.a === undefined are saying), you can ignore the null and normalize.

What if I'd like to distinguish the case where prop is null vs prop is not set (undefined)?

Then don't normalize?

_.get takes a string with dots in it too tho; I'm not sure it's something we need to align with.

@jridgewell

You don't need to normalize.

What @jhpratt and @lehni are saying is that if (null)?.b is null, then in a?.b?.c, the basic concept of "c was reached and has the value null" becomes complicated to express:

(a?.b ?? undefined)?.c

(a?.b ?? undefined)?.c

Ahh. I'm ok with that, given that the alternative is impossible to express with always-undefined semantics.

Ahh. I'm ok with that, given that the alternative is impossible to express with always-undefined semantics.

That's only a concern if there's a clear use case for the alternative though. 😞

The closest we've seen is @claudepache's myFutureCar?.color, but "if you dig into null, it's nulls all the way down" is a startling semantics, for precisely the reason expressed in the current FAQ text.

Otherwise, I can't help but feel that this thread is just xkcd 1172 for the a && a.b hack. 😅

noppa commented

As for @littledan's earlier comment, here's a quick rundown of the optional property getter functions in all the utility libraries that I could pull off the top of my head.

All tests use an object

obj = {
   a: null
}
Library Test Result
Underscore underscore.property(['a', 'b'])(obj) undefined
Lodash lodash.get(obj, 'a.b') undefined
Ramda R.path(['a', 'b'])(obj) undefined
Closure library goog.object.getValueByKeys(obj, 'a', 'b') undefined
Idx idx(obj, _ => _.a.b) null
Ember Ember.get(obj, 'a.b') undefined
Immutable Immutable.getIn(obj, ['a', 'b']) undefined

undefined seems to be the clear winner here. Whether that should affect the decision here is, of course, a separate matter.

Repo with the tests.

Chai's pathval (used in expect(...).to.have.nested.property('a.b')) returns null btw.

require('pathval').getPathValue({ a: null }, 'a.b') === null
require('pathval').getPathInfo({ a: null }, 'a.b')
// { parent: null, name: 'b', value: null, exists: false }
hax commented

@keithamus Note, pathval always returns null (aka, getPathValue({a:undefined}, 'a.b') also returns null). So semantically it's much closer to always returns undefined scheme.

hax commented

@claudepache

To be honest, your purchasePaintForCar example is too factitious to me.

The type signature of the three versions are:

  1. purchasePaintForCar(void)
  2. purchasePaintForCar(color?: Color)
  3. purchasePaintForCar(color?: Color | null)

IMO, use both default param (undefined) and null in the last version is very unclear without proper documentation and the api like that would never pass my code review.

And even the code will be confused:

function purchasePaintForCar(color = 'black') {
  if (color == null) return 
  ...
}

Some (good) programmers will suspect why we need if (color == null) return here if we already have default param, and they will guess it may be an old defensive-style code and decide to delete it in next commit...

Normally there are two easy way to refactor it.

  1. Use much clearer value like "transparent", "unknown" instead of null.
  2. Refactor the legacy car, add color property which value is always "black", or write an adapter (function fixLegacyCar(car) { car.color = 'black' }) --- it's the most desired and easy way because JS is a dynamic language. Then you can just use purchasePaintForCar(color: Color | null) (aka, drop default param, after all, the essential is: it's not about purchase default color, but some legacy car objects have default color) which is much easy to understand and maintain.

Why would defaultCar be null? In a server implementation I’d write, I’d probably omit the value entirely rather than inflate the bandwidth size just to match a strictly typed shape.

@ljharb It's not about what server-side should return, but you can't differentiate the nulls come from which position of the navigation path -- as I mentioned in #69 (comment) , these nulls very likely have very different meaning.

hax commented

I think we should go back to the start point of the semantics of "short-circuiting" (null?.x returns null) vs "existence check" (null?.x returns undefined).

As #69 (comment) , most library authors adopt "existence check". And me too.

This is because, when you write a long navigation path like a.b.c.d, mostly you are only care about the final d --- if we can get it, return the value of it, otherwise return undefined. It's just match the language: a.noExistPropName a[noExist] map.get(noExist) set.get(noExist)... all use undefined to denote non-existence.

On the other hand, the "short-circuiting" semantic is coming from a && a.b && a.b.c && a.b.c.d, but we should notice it's just a workaround! (it keep null/undefined just because we are lazy to write a strict pattern a == null ? undefined : a.b == null ? undefined : a.b.c == null ? undefined : a.b.c.d.) And, It seems the real reason of idx() (the only lib use "short-circuiting") use "short-circuiting" semantic is because they want to use it to replace old a && a.b && a.b.c pattern (aha, not surprise idx is coming from a big company like facebook). So the "short-circuiting" semantic is the by-product of the old && workaround pattern, not necessarily the real requirement of "safe navigation" or "optional chaining".

As for @littledan's earlier comment, here's a quick rundown of the optional property getter functions in all the utility libraries that I could pull off the top of my head.

Library Test Result
Underscore underscore.property(['a', 'b'])(obj) undefined
Lodash lodash.get(obj, 'a.b') undefined
Ramda R.path(['a', 'b'])(obj) undefined
Closure library goog.object.getValueByKeys(obj, 'a', 'b') undefined
Idx idx(obj, _ => _.a.b) null
Ember Ember.get(obj, 'a.b') undefined
Immutable Immutable.getIn(obj, ['a', 'b']) undefined

undefined seems to be the clear winner here. Whether that should affect the decision here is, of course, a separate matter.

Interestingly, of those libraries, only one (idx) is able to handle not only property accesses, but also method calls, like obj?.a.b().c. In fact, they all emulate obj?.a?.b, not obj?.a.b, and none of them handle correctly method calls, including idx.

By design, the current proposal does not restrict the “optional chain” to property accesses only.

It would be interesting to search for other JS-based libraries/languages/frameworks which, like idx, do not restrict themselves to property accesses, and have more capable short-circuiting semantics in case of chained property accesses. There are at least those two:

System Test Result Remarks
Coffeescript null?.a.b().c undefined
Angular (#58) null?.a.b().c null undefined?.a.b().c does also evaluate to null

@hax

I think we should go back to the start point of the semantics of "short-circuiting" (null?.x returns null) vs "existence check" (null?.x returns undefined).

Right.

As #69 (comment) , most library authors adopt "existence check". And me too.

This is because, when you write a long navigation path like a.b.c.d, mostly you are only care about the final d --- if we can get it, return the value of it, otherwise return undefined. It's just match the language: a.noExistPropName a[noExist] map.get(noExist) set.get(noExist)... all use undefined to denote non-existence.

Note that the scope of this proposal is wider than ”navigation path”. We have also included method and function calls, as in func?.(), obj.meth?.(), and obj?.meth(), as they have significant use cases.

On the other hand, the "short-circuiting" semantic is coming from a && a.b && a.b.c && a.b.c.d, but we should notice it's just a workaround! (it keep null/undefined just because we are lazy to write a strict pattern a == null ? undefined : a.b == null ? undefined : a.b.c == null ? undefined : a.b.c.d.) And, It seems the real reason of idx() (the only lib use "short-circuiting") use "short-circuiting" semantic is because they want to use it to replace old a && a.b && a.b.c pattern (aha, not surprise idx is coming from a big company like facebook). So the "short-circuiting" semantic is the by-product of the old && workaround pattern, not necessarily the real requirement of "safe navigation" or "optional chaining".

The short-circuiting semantics is not just about laziness, but about expressivity. When I write a?.b.c (with the approximate semantics of a && a.b.c), I assert that, if a is non-nullish, then a.b is not nullish. This is not a by-product of an old workaround, but a key part of the design of this proposal.

Interestingly, of those libraries, only one (idx) is able to handle not only property accesses, but also method calls, like obj?.a.b().c.

That's because instead of using strings, it's calling an anonymous function in a try-catch. Doesn't really seem like a laudable approach—"get me this if it exists" shouldn't imply that non-existence is an exception.

The short-circuiting semantics is not just about laziness, but about expressivity. When I write a?.b.c (with the approximate semantics of a && a.b.c), I assert that, if a is non-nullish, then a.b is not nullish.

I think there are two meanings of "short-circuiting" at play here—semantic (literal) and pragmatic (usage-based)—and it's only the latter that's in question. Semantically, a?.b shouldn't evaluate b, no problem. Pragmatically though, even if I use a && a.b.c to approximate a?.b.c, the meaning I convey with each of these is not the same. a?.b.c expresses a query for c, but a && a.b.c is an overt short-circuit that expresses "check as many of these as needed, and tell me about the last one that you needed to touch".

The aim of ?. should be to let us "say what we mean".

Thanks for the comparative work above, @claudepache @noppa @keithamus ! Initially, when I saw the bug report with the comparison from idx, I thought that this was a clear case of, let's go with the libraries and do null. Now, with this broad comparison, it might be leaning undefined, or maybe either option works.

@rkirsling That's because instead of using strings, it's calling an anonymous function in a try-catch. Doesn't really seem like a laudable approach—"get me this if it exists" shouldn't imply that non-existence is an exception.

Ick, indeed that code is buggy, or at least is incompatible with optional-chaining as specced in this repo, as we are not supposed to swallow any null-related errors, but only to test one specific value for null. It should’ve been:

function idx(input, accessor) {
    return input == null ? input : accessor(input)
}

Testcase: var a = { b: null}; a?.b.c should throw.

This is worse in case of calling a method, as they’ll swallow some errors throwed by that method.

(Have edited #69 (comment) after having considered more closely the real semantics of the tested libraries.)

if b would evaluate to null:

const a = null;
const b = a?.b;

Then I would assume that the next variables would evaluate: ...

const str = '';
const firstLetter = str[0];

  • firstLetter === ''

const numb = 0;
const length = numb?.length;

  • length === 0

const bool = false;
const active = bool?.active;

  • active === false

That could cause some hard to track down bugs.

It’s about the LHS being nullish only, so i don’t understand your examples.

@jEnbuska This discussion/thread concerns only undefined and null and whether the operator should evaluate to either of them, or one or the other it does not extend to all falsy values and as far as I am concerned both the committee, maintainers and champions have no intention of extending this to all falsy values for the obvious reasons that you stated

@ljharb I'm probably a bit over my head in this conversation

If something like this would happen in my code

const a = null;
a?.b // -> null

I would assume that something like this was happing underneath

!a ? a : a.b

@jEnbuska

I would assume that something like this was happing underneath
!a ? a : a.b

No, it would be a == null ? a : a.b.

phaux commented

Note that default values in arguments and destructuring patterns are only used on undefined so that:

function foo(x = 0) { console.log(x) }

// this logs 0
foo({}.x)

// this should also log 0, not null
foo(null?.x)

@phaux i disagree with "should" there. it will log 0 if null?.blah returns undefined, and it will return null if it returns null.

@noppa Do you mind if I use part of your table in #69 (comment) in an upcoming presentation I'm writing about optional chaining?

noppa commented

@littledan By all means do

@noppa Thanks!

I like this:

// b is undefined
let a = b?? 'default';

// b is undefined
let a = b.c ?? 'default';

// if c.x is not empty , will print original value
let c={x:1,y:2};
console.log(c.x ?: 'x is empty')
// this is ugly
const firstName = message?.body?.user?.firstName || 'default';

// maybe like this
const firstName = message.body.user.firstName ?? 'default';

@sndwow if you think it’s ugly, don’t write code like that (but i don’t agree). Those two things have very different semantics, and the second doesn’t allow you to, say, make body.user not optional.

Here is a concrete use case where null?.foo() is expected to be null. I have a variable that contains either a string or null/undefined. I want to normalise the string:

let xn = x?.normalize("NFC")

When x is null/undefined, I expect to have xn === x.

@claudepache can you elaborate more on why if x is null, you’d need xn to be null instead of undefined?

hax commented

I think the essential issue is what information we can get from the result, especially how differentiating null/undefined could be useful.

  • a.b throw TypeError means a is nullish
  • a.b === undefined means a is not nullish and do not have property b
  • a.b === null means a is not nullish and have property b with value null

If use TS/flow, compiler only allow a.b in the branch of a is not nullish, and eliminate the "do not have property" case, so

  • a.b === null means a is not nullish and have property b with value null
  • instead of throw, have to write if (a == null) { ... } for a is nullish
  • rarely use a.b === undefined (only useful in edge case: a have an optional property b in type T | null)

Basically, type system eliminate most foo === undefined, with the cost of writing if (foo != null).

Now let's introduce optional chaining operator, use a?.b instead of a.b

As current semantic,

  • a?.b throw TypeError means a is nullish
  • a?.b === undefined means a is nullish, or a is not nullish and do not have property b
  • a?.b === null means a is not nullish and have property b with value null

We can see current semantic just combines the first two cases and keep last unchanged.

If use TS/flow, it becomes

  • a?.b === undefined means a is nullish (so do not need write if (a == null))
  • a?.b === null means a is not nullish and have property b with value null (same as a.b === null mean)

As null?.foo === null semantic

  • a?.b throw TypeError means a is nullish
  • a?.b === undefined means a is undefined, or a is not nullish and do not have property b
  • a?.b === null means a is null, or a is not nullish and have property b with value null

We can see the first case are splitted and mixed into other two cases.

If use TS/flow, it becomes

  • a?.b === undefined is not very useful
  • a?.b === null means a is null, or a.b is null (a is not nullish and have property b with value null)

Summary

Current semantic make a?.b === null always have the same information as a.b === null, make a?.b === undefined have the same information as a == null || a.b === undefined. In ts/flow, a?.b === undefined have the mostly same information as a == null.

null?.foo === null semantic make a?.b === null have the same information of a === null || a.b === null, make a?.b === undefined have the same information of a === undefined || a.b === undefined. In ts/flow, a?.b === undefined is not very useful.

I think TS/flow users would definitely prefer current semantic, considering a?.b === undefined is not useful in null?.foo === null semantic.

Even only consider JS, in long chaining like a?.b?.c?.d,

As null?.foo === null semantic,

  • a?.b?.c?.d === undefined give a === undefined || a.b === undefined || a.b.c === undefined || a.b.c.d === undefined information
  • a?.b?.c?.d === null give a === null || a.b === null || a.b.c === null || a.b.c.d === null information

As current semantic,

  • a?.b?.c?.d === undefined give a == null || a.b == null || a.b.c == null || a.b.c.d === undefined information.
  • a?.b?.c?.d === null give a.b.c.d === null information

Could null?.foo === null have more use cases than current? I very doubt.

hax commented

And, I think current semantic is more consistent with semantic of nullish coalescing operator.

Actually I would like this proposal rename to "non-nullish chaining" or "nullish stop chaining" 😉

Isn't default the nail in the coffin for null?

I mean who wants to have to write foo(a?.b ?? undefined)?

@ljharb

@claudepache can you elaborate more on why if x is null, you’d need xn to be null instead of undefined?

I don’t need it to be specifically either null or undefined. But given that undefined and null have sometimes different semantics (as a fact of life, not judging whether it is appropriate), it may be dangerous to change null into undefined or vice versa in an otherwise unrelated operation (here, unicode normalisation). Compare:

foo(x);
// carelessly refactored as:
foo(x?.normalize("NFC"));

(Even if we agree that it would be generally weird for foo() to consider null as a reasonable input and to have a default parameter value different from null...)

Thanks for explaining, ftr it makes the most sense to me that optional chaining shouldn’t change the LHS.

@hax We can discuss at length what semantics is more appropriate in theory, and I don’t think we can find an answer that is always correct, even if there are chances that one of the semantics is more often correct. (But do note that I gave an example of a?.b(), not a?.b.) However:

I think the essential issue is what information we can get from the result, especially how differentiating null/undefined could be useful.

I am less worried about what information I get from a?.b (or a?.b(), etc.), for if I want to make the fine distinction between null and undefined based on information about a, I don’t mind to write one or two more lines of code in order to explicit my intent. (But I expect on contrary to add sometimes either ?? null or ?? undefined, whatever the outcome of this issue.) — I am more worried about information I accidentally change (as in my example).

@ljharb

optional chaining shouldn’t change the LHS

Unfortunately though, this phrasing already presupposes the "null passes through" viewpoint. The "always undefined"* camp would never describe this as "changing the left operand", because from this viewpoint, a?.b is purely "a query for b", and the value of a was never asked for in the first place.


* I actually fear that the phrase "always undefined" itself bakes in this presupposition; it may be better to call the two viewpoints "querying (for b)" and "preserving (a)" or similar. This would hopefully better reflect the fact that each camp is working from different axioms and that ultimately the committee will have to choose one or the other.

I guess i see a?.b as sugar for a == null ? a : a.b, and not a == null ? undefined : a.b or (a == null ? {} : a).b. Is there a different desugaring you have in mind that might help convince me?

I think I tend to view the specific desugaring as secondary, i.e., as a representation of the proposed semantics which must then be verified, as opposed to an expression of the fundamental motivation.

In particular, there's no expectation that the most convenient way to write something with ?. will be semantically equivalent to the most convenient way without it (after all, short-circuiting with && isn't completely safe, but one grins and bears it when considering the verbosity of the alternative).

Thus we could have a before-and-after scenario like this:

// before
function getFooStatus(options) {
  return options && options.fooData ? options.fooData.status : defaultStatus;
}

// after
function getFooStatus(options) {
  return options?.fooData?.status ?? defaultStatus;
}

These clearly aren't equivalent under any desugaring, but each might be called "the easy way" given the features available.

So while the desugarings you've mentioned represent the proposed semantics of each camp, I'm encouraging that we recognize that these representations merely follow from each camp's axioms—are we just querying for b or are we hoping that any non-undefined data would pass through from a?

Zarel commented

On the other hand, a.b !== undefined is my personal pattern for "does a.b exist?" – since I always use null and not undefined for nil values, !== undefined becomes an existence check. I would intuitively expect a?.b !== undefined to work the same way.

If a?.b falls back to a, then suddenly the result is ambiguous no matter what.

It's also nice if a?.b could be typed as T | undefined. T | undefined | null is a significantly less wieldy type, especially since it turns a?.b !== undefined into either a?.b != undefined (which is banned by default in many linters) or ![undefined, null].includes(a?.b) which is pretty ugly.

hax commented

@claudepache

foo(x);
// carelessly refactored as:
foo(x?.normalize("NFC"));

This is an interesting example. If I understand this example correctly, we are assuming foo(x) have different semantics for x being undefined or null, and in this code, x is null, so it's foo(null) before refactoring, and become foo(undefined) after refactoring as current semantic.

But similar cases also occur in other side:

  const {x} = bar || {};
  foo(x);
  // refactored to:
  foo(bar?.x);

Assume bar is null, using null?.foo === null semantic, refactoring will cause foo(undefined) change to foo(null).

If taking into account the JSON representation of javascript objects, I find null?.b === undefined as the best option, because, considering objects like:

1) o1 = { a: null }

and

2) o2 = { a: { b: null } }

I think of undefined as a value that was not defined in the object at all, while null as something explicitly defined with null, so in the first case o1?.a?.b === undefined (because there isn't a property a.b in o1) and in the second case o2?.a?.b === null (because there is a property a.b in o2 with the value null).

Also, o1?.a === null and o1?.c === undefined.

If o3 === null and o4 === undefined, then:

o3?.a === o4?.a === undefined
o3?.a?.b === o4?.a?.b === undefined
o3?.a?.b?.c === o4?.a?.b?.c === undefined
and so on...

That said, I would be okay with any approach, just want that this feature (optional chaining) be included in javascript.

I think of undefined as a value that was not defined in the object at all, while null as something explicitly defined with null, so in the first case o1?.a?.b === undefined (because there isn't a property a.b in o1) and in the second case o2?.a?.b === null (because there is a property a.b in o2 with the value null).

I agree

@lucasbasquerotto

I think of undefined as a value that was not defined in the object at all, while null as something explicitly defined with null

well, it depends on how you check.

const obj = { a: null, b: undefined };

Boolean(obj.a); // false
Boolean(obj.b); // false
Boolean(obj.c); // false

// property actually exists, but null or undefined
'a' in obj; // true
'b' in obj; // true
// property does not exist
'c' in obj; // false

// ignores undefined's
JSON.stringify(obj); // {"a":null}

// the properties really are in the object
Object.keys(obj); // ["a", "b"]

just providing more context/info to how js works. not expressing an opinion.

@obedm503 I don't think that was the point.

var a = {};
console.log(a.b); // undefined

@obedm503 I'm well aware that a key with an undefined value and a key that don't exists are not strictly the same in javascript, but:

1) A property defined with undefined and a non-existent property have the same value (in your example, obj.b === obj.c (both undefined) , but obj.a !== obj.c).

2) Like I said in that post: If taking into account the JSON representation of javascript objects[...], I was considering how an undefined property is passed around when converted to JSON (or, said in another way, how an object that contains it is serialized). In this case it doesn't make a difference between obj.b (declared as undefined) and obj.c (not declared).

3) It's common to see a variable defined as undefined as a syntactic sugar / language feature for properties defined dynamically, like var obj = { a: myVar }, where myVar comes from another place and may be undefined (and thus allowing complex objects having undefined properties, even tough the property is "defined"). So, in this situation, you don't care about the difference between an explicity or implicit undefined, only the value, that is the same in both cases.

4) This issue is mainly between null vs undefined, and I consider undefined as the best option, independently of considering a property explicitly defined with the undefined value, or not defined at all, because in both cases the value of the said property is undefined (and that is why I think (null)?.b should evaluate to undefined). See what @ghermeto said above.

What you said is valid and it's important to keep in mind, but I don't think it conflicts with what I said in that post and the reasons to choose null over undefined.

caub commented

It's quite surprising that null?.a.b evaluates to undefined, while (null?.a).b throws

But since
null?.a.b is null == null ? undefined : null.a.b and (null?.a).b is (null == null ? undefined : null.a).b it makes sense

It's quite surprising that null?.a.b evaluates to undefined, while (null?.a).b throws

Why on earth would you write (null?.a).b?

caub commented

I would not write this intentionally for sure, but it's valid syntax, and static property accessor is a left-to-right operator, so I could expect a same result

But I'm fine with the current status, it's just maybe something to be aware of

Now that the proposal is at stage 4, that won’t change.