tc39/proposal-optional-chaining

How does Nil affect non optional function calls

Closed this issue ยท 29 comments

xtuc commented

Initial discussion from Babel's Slack

From claudepache/es-optional-chaining:

Technically the semantics are enforced by introducing a special Reference, called Nil, which is propagated without further evaluation through left-hand side expressions (property accesses, method calls, etc.), and which dereferences to undefined (or to /dev/null in write context).

In this example what would be the ouput if b is null?

a?.b()

I expect this to throw but as far as I understand Nil will be propagated and b will not be called.

a?.b?.()

In the latter case we're using the optional chaining syntax so that the function call b() is conditional

What is the difference between the two examples?
What do you think about that?

As I see it, this:

a?.b()

Should be exactly like

(a?.b)()

And so, it should throw.

But this:

a?.b?.()

Should behave like so:

typeof (a?.b) === 'function' ? (a?.b)() : null;

And so should just give up on calling the function.

More examples can be found here

Yes, I agree (with the caveat that it's still an open question if the last conditional check is typeof function or == null)

I believe that checking if it's a function gives us not only more useful operator, but also it seems more intuitive as the only thing I meant to write is parentheses for function call.

I mean, without the new optionals I would write

func();

which (on my chrome console) will throw me the error Uncaught TypeError: func is not a function

Then iterating on my code trying to fix the problem I will just change it to:

func?.()

and expect it to not throw me the same error again..

I agree, but it makes ?. not be "always checking for == null" which is a potential consistency issue.

Which brings back the ?() syntax ๐ŸŽ‰ ๐Ÿ˜

But seriously, I think the way of thinking about it is as another operator really which is made of these ?.( characters bunched together.

I think this fits with the change from "null coalescing" to "optional chaining" as the later is more abstract as to what the ?. really does.

yuchi commented

Optional chaining is a specific usage of a soaked access, which includes soaked method invocation.
So having both ?. and ?( IMHO is more rigorous and correct.

o?.() is not an optional chain, but a soaked method invocation.

I'm going to reference several points here. Let's start with regular member access:

// Member Access
a.b?.c.d

// Rough ES5 
(a.b == null) ? void 0 : a.b.c.d

Here, optionality isn't deep. Only b may be nil, if a or a.b.c is it's got to throw.

// Delete
delete a.b?.c.d

// Rough ES5 
(a.b == null) ? void 0 : delete a.b.c.d

Notice the delete has been moved inside the alternate. Why? Because delete (a.b == null ? void 0 : a.b.c.d never deletes anything.

// Left hand assignment
a.b?.c.d = 42

// Rough ES5 
(a.b == null) ? void 0 : a.b.c.d = 42

Again, we have = 42 moved inside the alternate. Because expressions can't be the left hand side of an assignment.


Call cases:

func?.()

Should throw for consistency if func = null (or it's not declared yet). Doing typeof func == 'function' breaks consistency in two ways:

  1. All other ?.s only check for null
  2. We've added resolvable-ness to the mix without an equivalent to Identifiers.

Now what should deep calls do?

a.b?.c.d()

// Rough ES5
(a.b == null) ? void 0 : a.b.c.d()       // Option 1
((a.b == null) ? void 0 : a.b.c.d)()     // Option 2

I'm in favor of option 1. It matches the the delete and assignment cases from above (call gets moved into the alternate). And it reduces the use of the ugly ?.( optional call syntax and the mess we'd have to transpile it to (context, Function#call usage, lots of "built code").

We can do option 2, but I dislike it. Imagine the ES5 we'd write today to get this same kind of "call this always" (I'm omitting direct translation from option 2 back into IfStatements, because it's just silly):

let func;
if (a.b) {
  func = a.b.c.d;
} else {
  func = noop;
}
func();

To keep this context, it'd be even uglier:

let context;
if (a.b) {
  context = a.b.c;
} else {
  context = { d: noop };
}
context.d();

I just don't image people would really write that. Instead, they'd move the call into the consequent:

if (a.b) {
  a.b.c.d();
}

// Let's write that as an expression:
(a.b == null) ? void 0 : a.b.c.d();

That's option 1 above. ๐Ÿ˜€

Looking from a types point-of-view, a?.b.c.d?.() would mean that, not only I accept that a can be null|undefined, but I also accept that, even if a is an object that does have a valid .b.c chain, that a.b.c.d is allowed to be null|undefined. That should not necessarily be the case.

@jridgewell You've laid out the options quite well, I think - thank you.

I can accept that a?.b.c is saying "if a is not nullary, give me a.b.c, else give me a" - I think that follows well. It's basically a shortcut for conditional member access - ie, a ? a.b.c : a.

However, I strongly disagree with option 1; I think a?.b.c() should always be equivalent to (a?.b.c)() - which requires that the invocation be treated separately from the member access.

I would not want to see the proposal advance if a single ?. would put the rest of the chain in some kind of magic mode where invocations, too, are soaked up.

What about a?.b.c().d.e? Does the invocation magically stop the chained soaking, or does it continue? If the former, then I'd expect a?.b.c() to throw when a is nullary, for consistency. The latter seems like way too much magic for me.

I'd see it as:

a?.b.c().d.e

(a == null) ? void 0 : a.b.c().d.e;

I don't understand what "soaking" means, though. If we go back into IfStatements:

if (a != null) {
  a.b.c().d.e;
}

That seems perfectly normal to me (ignoring not doing anything with the expression in the consequent). I don't understand why'd there'd be a throw in that:

if (a == null) {
  throw new Error('why?');
} else {
  a.b.c().d.e
}

I like @Kovensky's example too. In this code, only a might not exist. If it does, it's guaranteed to be of some object type that has a b.c(), and that call returns an object that has d.e.

I do see the logic for your approach.

I think that having "add parens" (which is indistinguishable from "store in a var, and reference the var in the next statement") drastically change the behavior/meaning is very problematic.

xtuc commented

In the first transformation implementation, in the context of a optional function call I used Function() for the Nil reference.

This avoid an IfStatement and matches more to the spec (IMO) since i'm propagating the Nil reference.

The Nil reference explanation was relevant to me.

Oh, I think I get what you're thinking now:

a?.b.c().d.e

// Option 2 interpretation
((a == null) ? void 0 : a.b.c)().d.e

Translating this back into statements:

let c;
if (a != null)
  c = a.b.c;
}
c().d.e

Which again, I don't think people would actually write.


Translating it into an optional call, we get something like:

a?.b.c?.().d.e

// Option 2 interpretation
(((a == null ? void 0 : c = a.b.c) == null) ? void 0 : c().d.e

// statements
let c;
if (a != null) {
  c = a.b.c;
}
if (c != null) {
  c().d.e;
}

Which is ok, I guess. But man, it's gonna be slow (need a Function#call), means c can be null (@Kovensky's comment) , and its now requires two ?.s if there's any optionality in the callee.

Trying to default with this interpretation kinda sucks, too:

// Option 2 interpretation
// With "always" call
(a?.b || { c: () => ({d: { e: "default" } }) }).c().d.e

// With optional call
(a?.b.c?.() || {d: { e: "default" } }).d.e    // because `c == null ? void 0 : c()`
// statements
let c, ret;
if (a != null) {
  c = a.b.c;
}
if (c == null) {
  ret = {d: { e: "default" } };
} else {
  ret = c() || {d: { e: "default" } };
}
ret.d.e

I really dislike these default approaches because it duplicates so much of the chain. The only way out it to use 3 ?.s (and this has to coalesce c's return value):

a?.b.c?.()?.d.e || "default"

// statements
let c, ret;
if (a != null) {
  c = a.b.c;
}
if (c != null) {
  ret = c();
}
if (ret == null) {
  "default"
} else {
  ret.d.e || "default"
}

Instead, option 1's seems much simpler:

// Option 1 interpretation
// With "always" call
a?.b.c().d.e || "default"
// statements
if (a == null) {
  "default"
} else {
  a.b.c().d.e || "default"
}

// With optional call
a?.b.c?.().d.e || "default"
// statements
let c;
if (a != null) {
  c = a.b.c;
}
if (c == null) {
  "default"
} else {
  c().d.e || "default"
}

Sorry if taking over the entire conversation here. I'm really just trying to think through all the possibilities.

xtuc commented

Yes that's why I used Function() instead of an IfStatement.

b.?().e

Assume b is null, I would transform it into:

((b == null) ? Function : b)().e

Function().e returns undefined.

But the discussion is more about the syntax and the expected behavior, not about how effectively transform a function call.

Calling Function() is observable, fwiw - perhaps not in the spec, but certainly in the transpiler. You'd want babel to convert it to () => {} to avoid the observability.

(Also, I think CSP will error out in invoking the Function constructor)

I'm curious; what is unexpected about a?.b.c() compiling to a == null ? a : a.b.c(). Is unexpectedness the problem?

In case it's at all helpful, I was not able to find any signs of confusion about this aspect of the feature in CoffeeScript on stackoverflow or github; it might be useful if others could find this (there are plenty of questions about the feature, just none about this aspect).

C# also short-circuits, and I was similarly unable to find signs of confusion/surprise.

Are there any signs of "Option 1" behavior causing problems in the CoffeeScript or C# communities? If not, are there reasons it would cause problems in EcmaScript?

In terms of a == null ? a : a.b.c() being "magical", it's simply a question of crawling up through both MemberExpressions and CallExpressions. Both node types are very well-understood to be "parts of a chain", among the community and in the parser (eg; babylon's parseExprSubscripts).

@rattrayalex it's that putting arbitrary parens around any part of a chain doesn't change its meaning currently (afaik); this proposal changes that.

Can you explain further? Where would arbitrary parenthesis change the meaning?

@jridgewell see @xixixao's comments on #3.

I think it's an interesting argument.
Parens can only be added for precedence in a chain if the parens start at the beginning, since anywhere else they'd be a call. But all elements of a chain have the same precedence, so parens couldn't change their order. That changes here, where (x?.y).z should become (x == null ? x : x.y).z. (I don't believe the current implementation does this).

Personally that seems totally fine to me, as parens have always been available in chains but were never useful, but their addition only does what one would expect given their usage elsewhere in the language (and in virtually all programming languages).

But I see where @ljharb is coming from (thanks for the quick, concise explanation!). Hopefully I didn't butcher his reasoning.

I don't believe (x.y).z and x.y.z are different in AST (I know they're not in Babel's), so (x?.y).z and x?.y.z are the same. Do other JS ASTs differ?

Checking coffeescript, they definitely treat it differently. But C# follows what I wrote above (x?.y).<=> x?.y.z. And Ruby follows #3's OP, so they're completely on their own.

If you wanted to accomplish precedence like this, wouldn't you have to do it like:

(0, a?.b).c

Another consistency issue is that a.b.c() is always the same as const f = a.b; f.c() - a?.b.c() would not be the same as const f = a?.b; f.c().

That' more #3's discussion, a?.b.c and f = a?.b; f.c are different, too. These two issues are so similar. ๐Ÿ˜–

xtuc commented

Should we close this in favor of #3 ?

I think this is a separate discussion that just has to wait for #3. If we decide a?.b.c can throw on .c, then this issue can be closed. Otherwise, we still need to discuss it.

Personally, I would've expected the chaining/short-circuiting semantics to apply to either both or neither. The midpoint feels odd to me, since it's pretty common to have a chain of property accesses and method calls.

To answer the original question. Concerning what is specified on claudepache/es-optional-chaining:

In this example what would be the ouput if b is null?

I presume you wanted to ask โ€œif a.b is nullโ€.

a?.b()

I expect this to throw but as far as I understand Nil will be propagated and b will not be called.

If a itself is not null/undefined, no Nil reference is produced, and it will throw.


Concerning short-circuiting: Often, the distinction between property access and method call is not semantically relevant as in document.body vs. document.querySelector('body'), so that we should treat both cases the same way, whatever the fate of the short-circuiting feature will be.

Note also the notion of Nil is a spec artefact and is not how a user should think the feature.

The semantics as I envision is basically:

If the subexpression at the left of ? evaluates to null/undefined, the rest of the current chain consisting of property accesses, method or function invocations, object construction (new), and tagged templates is skipped, and the whole chain evaluates to undefined.

(โ€œObject constructionโ€, and โ€œtagged templateโ€ are here because they are at the same level of precedence in the spec, and they may be assimilated to method/function invocations.)

With #20, there will be no more Nil reference, so that the title of the issue will be obsolete.

I'm closing this issue in favour of #10.

As a side note, in a?.(), the check is a != null, not typeof a === "function". Providing a non-null, non-function value is most probably a bug and needs to always fail in order to help debugging. If you disagree, open a new issue with use case.