arthurfiorette/proposal-safe-assignment-operator

Discussion: Preferred operator/keyword for safe assignment

Not-Jayden opened this issue ยท 84 comments

Creating this issue just as a space to collate and continue the discussion/suggestions for the preferred syntax that was initiated on Twitter.

These options were firstly presented on Twitter here:


1. ?= (as !=)

const [error, data] ?= mightFail();
const [error, data] ?= await mightFail();

I generally agreed with this comment on the current proposed syntax:

syntax might be a little too close to the nullish coalescing assignment operator but i love where your head's at; errors as values would be amazing


2. try (as throw)

const [error, data] = try mightFail();
const [error, data] = try await mightFail();

Alternative suggestion for the await case from Twitter here

const [error, data] = await try mightFail();

3. try (as using)

try [error, data] = mightFail();
try [error, data] = await mightFail();

4. ? (as ! in TypeScript)

const [error, data] = mightFail()?;
const [error, data] = await mightFail()?;

๐Ÿ‘‰ Click here to vote ๐Ÿ‘ˆ


Please feel free to share any other suggestions or considerations :)

Worth pointing out that these aren't just different syntax options, they also already imply some differences about how they work; options 2 and 4 definitely look like expressions, e.g. something like bar(...(try foo())) could work, option 3 would introduce a new type of variable declarator (i.e. statement-level), and for option 1, I think some further clarification on what the syntax actually means is needed.

๐Ÿ‘‰ Click here to vote ๐Ÿ‘ˆ

Please feel free to share any other suggestions or considerations :)

@Not-Jayden You've shared the results page, maybe worth to update to the voting one.

Worth pointing out that these aren't just different syntax options, they also already imply some differences about how they work; options 2 and 4 definitely look like expressions, e.g. something like bar(...(try foo())) could work, option 3 would introduce a new type of variable declarator (i.e. statement-level), and for option 1, I think some further clarification on what the syntax actually means is needed.

Yep great callouts.

I was curious about the try (as using) suggestion. I thought it might have been a mistake at first, but I guess the assumption is try essentially always assigns the value to a const?

I was wondering if it would make more sense as a modifier of sorts rather than a declarator, so you could choose to do try const or try let (or even try var).

I can't say I'd be that concerned about confusing ?= with ??. On the other hand, reusing try feels out of place. I mean, language-wise, catch feels closer to what we're doing here. But I wouldn't use either as they introduce confusion with normal try-catch.

So far, I don't see anything better than ?=. The ? suggests we may not get a result back, at least. Sticking the ? on the end reads messy to me--keeping it next to the vars assigned feels better.

If we're bikeshedding the proposed syntax here .. ?= seems too similar to bitwise assignment operations (|= for example) and too similar to ?? and ??=. "safe assignment" is conceptually related to neither bitwise assignment nor nullish behaviors, so I'd suggest one of the try-block variant approaches.

@ThatOneCalculator just remove "results" from the url.

Voting page

why [error, data], not [data, error]?

why [error, data], not [data, error]?

@zoto-ff That's addressed in the proposal. https://github.com/arthurfiorette/proposal-safe-assignment-operator#why-not-data-first

On the other hand, reusing try feels out of place. I mean, language-wise, catch feels closer to what we're doing here.

Fair take. I prefer to think of it as an inline try

I also dislike using try and catch for the reasons previously mentioned.

However, I do like something about the last approach with ()?. It makes logical sense if you are only changing the return value of the called expression.

However coupled with optional chaining, it could simply return undefined, but when you reach the callable expression with ()?, it returns [value, error].

The "try (as throw)" reminds me of Scala Try util so maybe it's already invented.

To the tweet: try await ... seems to me more natural as I want to consume error+result of promise that is already settled.

Here are some examples to clarify what I was thinking

With the ?= assignment operator

const returnValue ?= objectThatCouldBeUndefined?.callableExpression();

In this case, returnValue should consistently be of type [error, value]. The syntax ensures that even if the callable expression fails OR the object is undefined, the returnValue will always adhere to this structure.

With ()?

const returnValue = objectThatCouldBeUndefined?.callableExpression()?;

Here, returnValue could either be [error, value] or undefined. This depends on whether objectThatCouldBeUndefined is indeed undefined. If it is, optional chaining will return undefined early; otherwise, callableExpression ()? will be called and it will return [error, value].

The "try (as throw)" reminds me of Scala Try util so maybe it's already invented.

To the tweet: try await ... seems to me more natural as I want to consume error+result of promise that is already settled.

Agree. If you leave out await, value in [error, value] should be a promise. With try await it should be the resolved promise.

Third option limit usage of a result with using. Or it will be using try [err, data] = await fn()?

I think this option should be disqualified.

I have a question about entire [error, data] structure. Are they expected to be raw returned/thrown data or wrapped objects like Promise.allSettled returns? If wrapped objects than why need two different variables instead of one? If not then how would a programmer know whether a function has thrown if error or data could be undefined, like this:

function canThrow() {
  if (Math.random() > 0.5) {
    throw undefined
  } else {
    return undefined
  }
}

upd: topic is raised already #3 (comment)

t1mp4 commented

To avoid confusion with tryโ€ฆcatch, a new keyword can be introduced:

const [res, err] = trycatch await fetch(โ€œโ€ฆโ€);

Itโ€™s not as aesthetically pleasing as โ€œtryโ€, but โ€œtrycatchโ€ is clearer and is easy to identify when scanning through code.

To avoid confusion with tryโ€ฆcatch, a new keyword can be introduced:

const [res, err] = trycatch await fetch(โ€œโ€ฆโ€);

Itโ€™s not as aesthetically pleasing as โ€œtryโ€, but โ€œtrycatchโ€ is clearer and is easy to identify when scanning through code.

I don't think that it would be confusing because the context seems pretty different to me. try catch is statement while this would be expression. try catch is followed by curly while this would not be as JS does not support block expressions... well actually if they would be supported some day, it might be confusing when I think about it. Even for parser I guess...

Alright, I think that I agree with you in the end ๐Ÿ˜„

Assuming block expressions exist, following code would mean "ignore the possible exception" (returned value [err, data] is ignored) however it's pretty confusing with classical try catch stmt. If catch/finally would not be syntactically required, parser would be helpless. Something as suggested trycatch or whatever new keyword would be much more readable in this case.

try {
  if (Math.random() > 0.5) {
    throw new Error("abc");
  }
  doSomething();
}

To avoid confusion with tryโ€ฆcatch, a new keyword can be introduced:

const [res, err] = trycatch await fetch(โ€œโ€ฆโ€);

Itโ€™s not as aesthetically pleasing as โ€œtryโ€, but โ€œtrycatchโ€ is clearer and is easy to identify when scanning through code.

I don't think that it would be confusing because the context seems pretty different to me. try catch is statement while this would be expression. try catch is followed by curly while this would not be as JS does not support block expressions... well actually if they would be supported some day, it might be confusing when I think about it. Even for parser I guess...

Alright, I think that I agree with you in the end ๐Ÿ˜„

Assuming block expressions exist, following code would mean "ignore the possible exception" (returned value [err, data] is ignored) however it's pretty confusing with classical try catch stmt. If catch/finally would not be syntactically required, parser would be helpless. Something as suggested trycatch or whatever new keyword would be much more readable in this case.

try {

  if (Math.random() > 0.5) {

    throw new Error("abc");

  }

  doSomething();

}

With ?= or ()? that wouldn't be an issue

With ?= or ()? that wouldn't be an issue

Personally I don't like the ()?as question mark is used in Rust but with different meaning. And lately many JS tools are getting rewritten to Rust so it might be confusing for people that use both.

Also it might be too much to wrap head around when it gets to the optional chaining. I can say that I trust you that it doesn't conflict anywhere however it seems to me that it requires a lot of thinking about the behaviour... But I might be wrong, maybe it's just needed to get used to it.

EDIT: the ?= wouldn't have any of these problems IMO. However I think that trycatch might be more powerful as it allows silencing the exceptions. However I believe that they should not be silenced anyway, so I don't know which is better. Just saying out loud thoughts.

Just throwing it out there, when I saw the proposal I initially thought it was for destructuring an optional iterable, kinda like the Nullish coalescing assignment but a little different (allowing the right side of an assignment to be nullish in the destructure).

const something: number[] | undefined = [1, 2, 3]; // or = undefined;
const [a, b] ?= something;

[a, b] ?= something at first glance looks like optional destructing of a value with a Symbol.iterator function (but yeah not a thing yet)

[error, data] ?= await promise confuses me a lot. And we have something that would sit in this place already.

We have a prior notion of settled promises, which would fit in this space for promises specifically (mentioned here too)

const [{ status, reason, value }] = await Promise.allSettled([fetch("/")]);

// console.log({ status, reason, value });
// {status: 'fulfilled', reason: undefined, value: Response}

For promises we could have a nicer function here... Promise.settled

const { status, reason, value } = await Promise.settled(fetch("/"));

Then it brings up, is this what is wanted, but for any object

const { reason, value } [settled] something

This closes any question of this vs that first (as ordering is optional), and drops the comparison to destructing iterator values. It also allows any additional keys to be defined on that returned value, not just status, reason, and value

Giving an example with a symbol not yet suggested, ~, a tilde, suggesting that the value assignment is similar to the result of the object/action/function return/whatever, but its not really the same as what you expect in runtime (I know this interacts with Bitwise NOT, but the following isn't valid syntax as is, this would be some kind of assignment only expression)

The tilde (~)

Its freestanding form is used in modern texts mainly to indicate approximation

const { value } ~ something

Then with a promise it can be reasoned with maybe...

const { value } ~ await promise 

This does force a rename instead of an iterator like destructing if you didn't want to use value and reason though.

Instead of Symbol.result... could it be Symbol.settle if it was a notion of settling a result of a settleable value


For functions, if it was to settle a function call, all has to be a single statement.

const { value } ~ action() 
const { value } ~ await action()

Outtakes
const { value, reason } settle something 
const { value, reason } settle await promise

... Or just cause I wrote the word "maybe" earlier

const { value, reason } maybe something 
const { value, reason } maybe await promise

Just throwing it out there, when I saw the proposal I initially thought it was for destructuring an optional iterable, kinda like the Nullish coalescing assignment but a little different (allowing the right side of an assignment to be nullish in the destructure).

const something: number[] | undefined = [1, 2, 3]; // or = undefined;

const [a, b] ?= something;

[a, b] ?= something at first glance looks like optional destructing of a value with a Symbol.iterator function (but yeah not a thing yet)

[error, data] ?= await promise confuses me a lot. And we have something that would sit in this place already.

We have a prior notion of settled promises, which would fit in this space for promises specifically

const [{ status, reason, value }] = await Promise.allSettled([fetch("/")]);



// console.log({ status, reason, value });

// {status: 'fulfilled', reason: undefined, value: Response}

For promises we could have a nicer function here... Promise.settled

const { status, reason, value } = await Promise.settled(fetch("/"));

Then it brings up, is this what is wanted, but for any object

const { reason, value } [settled]= something

This closes any question of this vs that first (as ordering is optional), and drops the comparison to destructing iterator values. It also allows any additional keys to be defined on that returned value, not just status, reason, and value

Giving an example with a symbol not yet suggested, ~, a tilde, suggesting that the value assignment is similar to the result of the object/action/function return/whatever, but its not really the same as what you expect in runtime (I know this interacts with Bitwise NOT, but the following isn't valid syntax as is, this would be some kind of assignment only expression)

const { value } ~ something

Then with a promise it can be reasoned with maybe...

const { value } ~ await promise 

This does force a rename instead of an iterator like destructing if you didn't want to use value and reason though.

Instead of Symbol.result... could it be Symbol.settle if it was a notion of settling a result of a settleable value


For functions, if it was to settle a function call, all has to be a single statement.

const { value } ~ action() 
const { value } ~ await action()

Outtakes
const { value, reason } settle something 
const { value, reason } settle await promise

... Or just cause I wrote the word "maybe" earlier

const { value, reason } maybe something 
const { value, reason } maybe await promise

Without = equal sign at all it makes it hard to see that it's an assignment.

Agreed, I had originally ~= but when it came to the function calls it was very clear it was "something different" and I dropped the equal sign, here is a comparison:

const { value } ~ something
const { value } ~ await promise 
const { value } ~ action() 
const { value } ~ await action()

const { value } ~= something
const { value } ~= await promise 
const { value } ~= action() 
const { value } ~= await action()

Both are reasonable. It feels obvious though that something else is happening here. This is not assignment until destructing right? Unless there is some way to assign this intermediate representation, which, actually makes sense too

const settled ~= await action();

if (settled.status === "fulfilled") {
  console.log(settled.value);
} else {
  console.log(settled.reason);
}

Well maybe it should't be an assignment at all. What if I want to pass it directly as parameter?

doSomethingWithResult([some keyword] await f())
const settled ~= await action();
doSomethingWithResult(settled)
const settled ~= await action();
doSomethingWithResult(settled)

Obviously, but keyword would allow me do it directly.

(Comments too quick, had edited to include but will shift down ๐Ÿ˜„)

But if just a single expression

doSomethingWithResult(~= await action())

Or the reduced

doSomethingWithResult(~ await action())
doSomethingWithResult(~ syncAction())

This shows where = equals could be dropped, then if its dropped in one place, drop it throughout.

(Comments too quick, had edited to include but will shift down ๐Ÿ˜„)

But if just a single expression

doSomethingWithResult(~= await action())

Or the reduced

doSomethingWithResult(~ await action())
doSomethingWithResult(~ syncAction())

This shows where = equals could be dropped, then if its dropped in one place, drop it throughout.

Possibly, but if I understand correctly all the behaviour, then all these would be possible despite doing the same:

const result ~= action();
const result = ~= action();
const result = ~ action()

It seems to me that ~= works as both binary and unary operator. I think that ~= should be strictly binary (assignment + error catching) and ~ should be unary (however ~ already exists as binary not as you mentioned and whitespace makes no difference).

Definitely interesting. Maybe in that case the try or trycatch keyword would be better?

Without the equals, as a unary operator only, it would be turning the value to its right into a settled object.

Where assignment or destructing happens could then be outside of the problem space for the operator.

const { value } =~ something and const { value } = ~ something where the spaces are optional/ignored seems consistent. (Note this is swapped around in characters compared to earlier comments)

const { value } = try something
const { reason } = try await promise

const settled = try something

doSomething(try something)
doSomething(try await something)

try seems natural as a unary

Unfortunately, the syntax is bound to be the most controversial and drawn out discussion item.

But on the other hand, the polling indicates 2/3 developers are totally fine with the unary/inline try syntax (I guess its pretty intuitive - it doesn't feel like another "thing" to memorize):

try_as_throw

I think this suggestion to just add try as a static method of Function from the previous iteration of this proposal is worth consideration. Definitely a much simpler change.

const [error, data] = Function.try(mightFail);
const [error, data] = await Function.try(mightFail);

I think what I like about option 2. Try as throw syntax
Is I can imagine writing it in parenthesis (try โ€ฆ) or wrapping its results producing statement in parenthesis try (โ€ฆ) which can span multiple lines

what about this?

const data = mightFail() catch (error) {
  handle(error);
  return;
};

what about this?

const data = mightFail() catch (error) {

  handle(error);

  return;

};

I think in that case just making the current try catch block return a value would be better.

const result = try {
    return someUndefinedFunction(); 
} catch (error) {
    return "An error occurred";
};

But this proposal is more about the concept "error as a value" like in go.

const [error, data] = ...

errors are already values, this is about more ergonomic catching. since status quo try/catch has many issues outlined in and outside the readme. and the error handling style in go is widely considered one of its worst parts.

nothing stops present day folks from doing return new Error instead of throw new Error. that would be particularly fine to do once https://github.com/tc39/proposal-is-error lands. in which case the pattern this proposal currently provides would be usurped by:

const [error, data] ?= mightFail();
if (error) {
  // handle 'error'
}
// it succeeded, we can use 'data'

vs

const data = mightFail();
if (Error.isError(data)) {
  // 'data' is an error
}
// mightFail succeeded 'data' is data

@nektro What is the advantage of the former over the latter here?

errors are already values, this is about more ergonomic catching. since status quo try/catch has many issues outlined in and outside the readme. and the error handling style in go is widely considered one of its worst parts.

nothing stops present day folks from doing return new Error instead of throw new Error. that would be particularly fine to do once https://github.com/tc39/proposal-is-error lands. in which case the pattern this proposal currently provides would be usurped by:

const [error, data] ?= mightFail();

if (error) {

  // handle 'error'

}

// it succeeded, we can use 'data'

vs

const data = mightFail();

if (Error.isError(data)) {

  // 'data' is an error

}

// mightFail succeeded 'data' is data

But the point is that with this proposal, you can universally apply it to any callable expression that might throw an error, making it more ergonomic and consistent. Also, the idea that Goโ€™s error handling is โ€œwidely considered one of its worst partsโ€ is debatable. Thereโ€™s actually a lot of support for Goโ€™s approach, and this proposal is also getting significant backing in the JavaScript community.

Iโ€™m definitely in favor of this proposal. It addresses a big problem I encounter in real life all the time, especially with tasks like fetching data and similar operations.

@nektro What is the advantage of the former over the latter here?

i might've worded it a bit weird before the code blocks. that was exactly my point. the latter is what i prefer and possible through a different proposal if code is changed to return error instead of throw error. then thrown errors become tantamount to a panic in other languages and the scoping issues of try/catch become less of an issue because unahandled exception handling becomes akin to go's recover()

but i mainly prefer the first comment i left since that doesnt require any code to be rewritten out in the ecosystem and uses an error handling pattern thats far more robust than either of those two code blocks in my latter comment

... you can universally apply it to any callable expression that might throw an error, making it more ergonomic and consistent.

My main problem with this is that once you use this new syntax in a block of code, it loses its ability to throw. Thus I don't agree that it is more ergonomic nor consistent.

Regular errors are consistent because they are always thrown and caught. Once you lift them in this new tuple realm, the consistency is broken. If there was a mechanism to maintain this property (somehow propagating these pair of values without manual intervention) it would be ok, but it just changes it in a form that is not compatible with the existing error handling mechanism.

I don't even think this is "more ergonomic catching" @nektro.

This is not related to the original discussion but it seems to be veering of.

... you can universally apply it to any callable expression that might throw an error, making it more ergonomic and consistent.

My main problem with this is that once you use this new syntax in a block of code, it loses its ability to throw. Thus I don't agree that it is more ergonomic nor consistent.

Regular errors are consistent because they are always thrown and caught. Once you lift them in this new tuple realm, the consistency is broken. If there was a mechanism to maintain this property (somehow propagating these pair of values without manual intervention) it would be ok, but it just changes it in a form that is not compatible with the existing error handling mechanism.

I don't even think this is "more ergonomic catching" @nektro.

This is not related to the original discussion but it seems to be veering of.

But that's the whole point. You catch the error. Why would that be inconsistent? It's just like putting a try block around it.

I actually rather agree with @nektro and understand this proposal as an adoption utility tbh.

Let's say I am up to returning errors but my underlying function is "exception based" so I wrap it into "trycatch" (or whatever syntax is accepted).

In other words, I don't understand why I should use exception system while it apparently does not suit my needs.

@alexanderhorner then how is this different from just returning the error?

@alexanderhorner then how is this different from just returning the error?

This is about handling errors. For example fetch throws errors in some scenarios. That's how fetch was designed. You can't change it to return errors instead of throwing them. You can however make alternative ways to try ... catch blocks to better handle them. This is what this proposal is about.

Try Catch Blocks

// Function using traditional try...catch blocks
const fetchWithTryCatchBlock = async () => {
  let response;
  try {
    response = await fetch('https://api.example.com/data');
  } catch (fetchError) {
    console.error('Failed to fetch:', fetchError);
    return [];
  }

  let json;
  try {
    json = await response.json();
  } catch (parseError) {
    console.error('Failed to parse JSON:', parseError);
    return [];
  }

  // Return json.items or an empty array if json.items doesn't exist
  return json.items || [];
};

New Syntax

// Function using the proposed error handling syntax
const fetchWithProposedSyntax = async () => {
  // [error, data] using ?= operator
  const [fetchError, response] ?= await fetch('https://api.example.com/data');

  if (fetchError) {
    console.error('Failed to fetch:', fetchError);
    return [];
  }
  
  // [error, data]
  const [parseError, json] ?= await response.json();

  if (parseError) {
    console.error('Failed to parse JSON:', parseError);
    return [];
  }

  // Return json.items or an empty array if json.items doesn't exist
  return json.items || [];
};

// Usage
fetchWithProposedSyntax().then(items => console.log('Fetched items:', items));
const UserPostsWithTryCatch = async ({ userId }) => {
  let posts;

  try {
    const response = await fetch(`https://api.example.com/users/${userId}/posts`);
    posts = await response.json();
  } catch (error) {
    // Handle the error by simply not assigning any value to posts, allowing it to remain undefined
    console.error('Failed to fetch or parse posts:', error);
  }

  // If fetching posts failed, posts would be undefined, which the optional chaining operator `?.` will handle
  return (
    <div>
      {posts?.map(post => (
        <UserPost key={post.id} post={post} />
      )) ?? <div>No posts available.</div>}
    </div>
  );
};
const UserPosts = async ({ userId }) => {
  // Attempt to fetch user posts and ignore the error if it fails
  const [error, posts] ?= await fetch(`https://api.example.com/users/${userId}/posts`)
                              .then(response => response.json());

  // If fetching posts failed, posts would be undefined, which the optional chaining operator `?.` will handle
  return (
    <div>
      {posts?.map(post => (
        <UserPost key={post.id} post={post} />
      )) ?? <div>No posts available.</div>}
    </div>
  );
};

fetch example from above with my version (the use of the catch keyword is illustrative to avoid bikeshed)

const fetchAlternative = async () => {
  const response = await fetch('https://api.example.com/data') catch (fetchError) {
    console.error('Failed to fetch:', fetchError);
    return [];
  };
  const json = await response.json() catch (parseError) {
    console.error('Failed to parse JSON:', parseError);
    return [];
  };
  return json.items || [];
};

// Usage
fetchAlternative().then(items => console.log('Fetched items:', items));

fetch example from above with my version (the use of the catch keyword is illustrative to avoid bikeshed)

const fetchAlternative = async () => {

  const response = await fetch('https://api.example.com/data') catch (fetchError) {

    console.error('Failed to fetch:', fetchError);

    return [];

  };

  const json = await response.json() catch (parseError) {

    console.error('Failed to parse JSON:', parseError);

    return [];

  };

  return json.items || [];

};



// Usage

fetchAlternative().then(items => console.log('Fetched items:', items));

Yes, but in that case just use the current try catch syntax and make it return something imo.

@nektro @alexanderhorner
The problem I with putting error to lower scope "enforces" (or rather leads) programmer to early return and kinda blocks doing anything else with it IMHO.

@alexanderhorner then how is this different from just returning the error?

This is about handling errors. For example fetch throws errors in some scenarios. That's how fetch was designed. You can't change it to return errors instead of throwing them. You can however make alternative ways to try ... catch blocks to better handle them. This is what this proposal is about.

Then, it seems like a very weak case for adding new syntax compared to #9 or #15. If this is really that important, I am closer to @nektroโ€™s original proposal above. It is just a variation of Promise .catch which also extends to non-Promise values. It is unopinionated how the data is actually handled and is very similar to the existing usages.

@alexanderhorner then how is this different from just returning the error?

This is about handling errors. For example fetch throws errors in some scenarios. That's how fetch was designed. You can't change it to return errors instead of throwing them. You can however make alternative ways to try ... catch blocks to better handle them. This is what this proposal is about.

Then, it seems like a very weak case for adding new syntax compared to #9 or #15. If this is really that important, I am closer to @nektroโ€™s original proposal above. It is just a variation of Promise .catch which also extends to non-Promise values. It is unopinionated how the data is actually handled and is very similar to the existing usages.

This completely ignores the annoyance with return values, closures etc.

If every proposal would be shot down by the "it's just a small improvement, not worth it"-crowd, we would still be stuck with jquery and in callback hell...

This completely ignores the annoyance with return values, closures etc.

Maybe I missed those concerns? It would help if you can describe the problems with it.