Optional chaining assignment

Hi everyone,

I'm pretty sure I've already seen this idea somewhere, but I'm not able to find anything about it, so here I go (and sorry if this is duplicate).

The issue

There is a pattern which is encountered quite often: setting properties of a nested object, where that nested object has potentially not been initialized yet.

myObject.sub.value = 3 // `sub` could potentially be undefined

Here are some of the "cleanest" ways I could think of for solving this problem today:

// Using the nullish coallescing operator
myObject.sub ??= {};
myObject.sub.value = 3;

// Using ternary condition
myObject.sub = !!myObject.sub ? 3 : { value: 3};

// Using lodash
_.set(myObject, ['sub'], 3);

The major drawback of the first solution is that it is a two-step solution, and we are repeating the object name twice.
The second solution is a oneliner, but is much less readable, and we now repeat the object name AND the assigned value twice.
And for the third one, we have to rely on an external library to achieve our goal.

The solution

In order to improve code (and developer life) quality in such cases, I propose to reuse the optional chaining ?. operator, but on the assignment side:

myObject.sub?.value = 3;

This would desugar to something like:

if (typeof myObject.sub != 'object') myObject.sub = {};
myObject.sub.value = 3;

The bracket notation would also be supported:

const myKey = 'value';
myObject.sub?.[myKey] = 3;

would desugar to:

const myKey = 'value';
if (typeof myObject.sub != 'object') myObject.sub = {};
myObject.sub[myKey] = 3;

This syntax would come very handy, especially when we have to deal with deep nested maybe-undefined properties:

myObject.a?.sub?.property?.value = 3;

Some implementation notes:

  • If a nested property is already defined but is not and object, it will be overridden (if writable)
const myObject = {};
myObject.a = 3;
myObject.a?.value = 3;
// myObject === { a: { value: 3 }}
const myObject = {};
Object.assignProperty(myObject, 'a', { writable: false, value: 3 });
myObject.a?.value = 3;
// myObject === { a: 3 }
  • Using ?. on the first object should not be allowed, as it could lead to errors / unintuitive behaviors (especially if the variable is constant and/or set to a primitive).
myObject?.value = 3 // SyntaxError

What if a nested property should be something other than an object?

A question that might raise is: what if a nested property should not be a simple object, but an array, or a custom class object, or even any possible value you'd like? Would there be a syntax to support such cases?

In my opinion, I don't think this would be a good idea to include these particular cases (at least not for this proposal).

There are to main reasons for this:

  • First, I can't come with a clean, readable and intuitive solution for such a use case (if you can find one, please feel free to share it!)
  • Secondly, initializing custom objects on the fly could be quite a complex operation, which might require proper setup instead of using a shorthand syntax. So I'm not sure it would be a good thing to have a dedicated syntax for that anyway.

So in the end I think it would be better to focus first on the initial problem, which is simpler and more common, and then extended it in another proposal if this first one is somehow successful.

Thoughts?

Please share your thoughts! :)

(Also if you can come with a better name for "optional chaining assignment" it would be great!)

There's a lot of use cases where you'd want a plain object, or a null object, or an array; i'm not sure it makes sense to add a syntax feature if it can only create one of these.

myObject?.value = 3

If I saw such syntax I'd guess that it checked if myObject is null/undefined then do nothing else run the assignment.

You probably know you can do:

(myObject.sub??={}).value = 3;

This isn't super elegant though for multiple nested items.

((myObject.sub??={}).sub2??={}).value = 3;

You'd probably want a new syntax that's similar but without the parenthesis. As mentioned you'd want the ability to assign any default object or array to make it flexible.

5 Likes

Every time I've attempted/wanted to use optional chaining with assignment, it was with the intention of having this behavior. I wouldn't want anything created for me. I just want to do something if it's there, otherwise nothing.

2 Likes

Maybe this thread? Deep creation of object...

Strongly agree with @sirisian, I would except obj?.foo?.bar?.baz = value to be equivalent to something like

if (obj?.foo?.bar == null || typeof obj.foo.bar != "object" && typeof obj.foo.bar != "function") return;
const descriptor = Object.getOwnPropertyDescriptor(obj.foo.bar, "baz");
if (descriptor?.writable || !descriptor && Object.isExtensible(obj.foo.bar)) obj.foo.bar.baz = value;

With the naming of β€œoptional chaining assignment”, the assignment should be optional, and only assign a value if it is possible to do so, while silencing any non-assignable errors.

Maybe we should not check the assignability of the last β€œbaz” in the above example though, in order to keep it consistent. Another syntax like obj?.foo?.bar?.baz? = value should be preferred.

It should create null object. This thing is generally used for some sort of hierarchical configuration/state, and plain objects are not the right tool for that.

As for arrays, having syntax for those wouldn't be all that useful. You'd almost always end up with a sparse array.

// let's say we want this:
//       ,__ set foo.bar = Object.create(null)
//      /
foo.bar?.deep?.[index] = value
//            \
//             \__ set foo.bar.deep = []

For the rare case where you really want to set some element in a nested sparse array, you'd combine the new syntax with the current:

(foo.bar?.deep ??= [])[index] = value

Then I recommend you explicitly write

((foo.bar ??= Object.create(null)).deep ??= [])[index] = value

That's great to have some feeback! :smiley:
(sorry, took a bit of time to reply)

Ah indeed that's a very similar idea, that might be it!
Even if in my memories I think there was the ?. used inside the identifier.

I think the advantage using the ?. syntax like I proposed is that it should not break any previous code, as for today this is invalid syntax.

Yes, that's exactly my point!

Indeed it should have been better if I used this oneliner you wrote, instead of the one I chose in my proposal, this would have make my point of more understandable. I think I'm going to update my proposal to reflect this!

I do understand having a syntax for all these other cases might seem useful, but actually I really don't think this is the case.

Let's say we have the following syntax for specifying any default value, so instead of writting:

((myObject.nested ??= new MyClass()).values ??= [])[0] = 3

we could write:

myObject.nested?( new MyClass() ).values?( [] ).[0] = 3;

This example worth what it worth, it is only useful for me to demonstrate my point, but I think whatever syntax we use we can deduce the following pros and cons:

Pros:

  • The default assignment can now be chained in a linear way without nested parentheses

Cons:

  • This is not really shorter: 3 characters instead of 5 (which is already quite short for what it is achieving), here we only save on the wrapping parentheses
  • This is not really easier to write / read, and especially this does not look idiomatic to the language. On that point I think this will be hard to find a syntax that will meet this criteria while not worsening other aspects of the design (like length)

But also, and this is my biggest point, this would allow to write monstrous code like:

myObject.nested?({
  aProperty: 4,
  someItems: [],
  somethingElse: 'some other data',
}).sub?(
  new MyClass('param')
).array?(
  []
).[0] = 3;

And this is possible only because we allowed to specify default value on the fly in the first place in the design. I don't see how it would be possible to prevent this without preventing this whole syntax.

One could argue that this kind of monstrosity is already possible using the (myObject ??= whatever) syntax, but this is already considered as kind of a hack for lack of better. And this is not something I'd want to encourage in a proposal that would not fix this hack but only make it easier to write.

When dealing with complex data structure, potentially with state, one should take the time to setup everything properly, and not on the fly as a side effect during assignment.

The correct way of doing this should be the following, even if it is longer:

myObject.nested ??= {
  aProperty: 4,
  someItems: [],
  somethingElse: 'some other data',
};
myObject.nested.sub ??= new MyClass('param');
myObject.nested.sub.array ??= [];
myObject.nested.sub.array[0] = 3;

The scope of this proposal is to only help handling simple data in a simple way, and not complex data in a complex way.
I believe these kinds of usecases occur often enough so it was worth to create a proposal, but I could have been mistaken.

The only downside I do realize about this restriction is that we miss the ability to create arrays on the fly, which could still be handy for some usecases.

If someone can come with a dedicated syntax that covers this specific case only while integrating smoothly in the language (on my side I was not able to find such design), I would be happy to integrate it into the proposal.

But for now, I just prefer to leave this aside.

The nested assignment problem can be handled without nested parentheses with the pipeline operator as well.

(myObject.sub ??= {}) |> (%.sub2 ??= {}) |> %.value = 3

Though, this sort of thing still feels a little hacky.

1 Like

That's weird, there seem to be here a kind of consensus on that, but that's miles away to the way I'm seeing this! :p

In my point of view, it would make much more sense that the behavior you are describing is indicated by the equality operator, and not somewhere locally in the identifier.

For example, something like:

myObject.nested.value ?= 3; // if it throws a TypeError, fail silently

Having an operator like this occurring a specific location in an identifier leads me to think that it is doing things locally at this place specifically, and not during the assignment later.

Haha, seems like for any problem we are exposing on this forum, there will always be a way to solve it using pipeline operators! :smile:

2 Likes

I agree with Clemdz that this wouldn't make sense. Assignment expression lval = rval is defined as 1. evaluate lval, 2. evaluate rval, 3. put rval into lval, 4. return rval. This proposal is only changing the 1st step. In your interpretation if lval was for example a.b?.c and a.b == null, then rval wouldn't even get evaluated -- effectively making the whole assignment optional -- that's something that should be communicated on the assignment operator, not somewhere on the LHS.

1 Like