arthurfiorette / proposal-safe-assignment-operator

Draft for ECMAScript Error Safe Assignment Operator
https://arthur.run/proposal-safe-assignment-operator/
MIT License
1.38k stars 13 forks source link

Discussion: Preferred operator/keyword for safe assignment #4

Open Not-Jayden opened 2 months ago

Not-Jayden commented 2 months ago

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 :)

alexanderhorner commented 2 months ago

@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...

anacierdem commented 2 months ago

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.

lucasconstantino commented 2 months ago

If this is a syntax change, why even relying on tuple or objects?

const error or data = try foo()

I mean, I'm not at all bound to the "or" syntax above, just I feel we could bring clarity and constraint if we be a bit less bound to known structures...


Also, as for typing, that's TypeScript's problem then, ain't it?

function foo(): ResultType or throws ErrorType {}

Not something we should care in here I suppose?

ghaerdi commented 2 months ago

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));

I really like this approach, it reminds me to Zig or Rust's Result.unwrap_or_else method. I think would add a way to set a default value for the variable when there's is an error.

Something like:

const result = divide(10, divisor) catch (error) {
  console.error("failed to divide:", error);
  default 0; // assign 0 to result
}

console.log(result); // output should be the result of the math if successful or 0 if error
zackjrwu commented 2 months ago

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

Placing the error before the data, I guess, is to emphasize the importance of the error.

manniL commented 2 months ago

I wonder whether { error, data } could be a viable option instead of a tuple where order matters šŸ¤”

fabiancook commented 2 months ago

@manniL worth checking out the settled result from Promise.allSettled, matches but uses the word reason instead of error.

A rejection or caught value may not be an error

Have detailed a Promise.settled alternative above for promise values.

crazytonyi commented 2 months ago

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

I think it's funny that people are pushing back on using existing keywords (a fair concern) but we also resist inventing an entirely new keyword.

To me, this illuminates how spiritually similar this new idea is to the thing it is replacing. try is what we want to do (give it a try).

Someone suggested trycatch, which at that point, why not a totally new keyword, like attempt or safely?

I think the ? also must feel exhausted at this point as well.

crazytonyi commented 2 months ago

I wonder whether { error, data } could be a viable option instead of a tuple where order matters šŸ¤”

Is there a scenario where it would return both data and errors? If not, then why not have it just store the error as an object of the response?

angelhdzmultimedia commented 2 months ago

I wonder whether { error, data } could be a viable option instead of a tuple where order matters šŸ¤”

Yeah like an npm library called await to that I can't find now šŸ¤”:

async function to(promise) {
  const response = { data: null, error: null }
  try {
     response.data = await Promise.resolve(promise)
  } catch (error) {
    response.error = error
  }
  return response
}

const { error, data } = await to(somePromise())

Promise.withResolvers does this:

const {promise, resolve, reject} = Promise.withResolvers()
arthurfiorette commented 2 months ago

@angelhdzmultimedia, https://github.com/arthurfiorette/tuple-it

angelhdzmultimedia commented 2 months ago

@angelhdzmultimedia, https://github.com/arthurfiorette/tuple-it

But that returns array/tuple. We talking about an object with props. šŸ¤·ā€ā™‚ļø

nektro commented 2 months ago

it doesnt return an object for the same reason result doesnt go first. would make it to easy to implicitly ignore the error and would make reading the code harder

crazytonyi commented 2 months ago

it doesnt return an object for the same reason result doesnt go first. would make it to easy to implicitly ignore the error and would make reading the code harder

I agree with you in spirit, but doesn't this whole thing risk implicitly ignoring the error? Unless I'm missing something, there's nothing catching the error and forcing a reaction to it. This feels more like optional chaining where part of the likely value is avoiding error handling by saying "do this and just assume I don't want it to break if there's a problem". (Legit not clear on how this forces better error handling)

nektro commented 2 months ago

its lined out in https://github.com/arthurfiorette/proposal-safe-assignment-operator#why-not-data-first

rafageist commented 2 months ago

Why a new word in the middle? I think that the proposal for both the operator and this keyword are unnecessary. Right now I don't need the approval of the proposal to achieve the same thing. Even this can be done in many ways, this is just an example:


const attempt = (operation) => {
   let result = null;
   let error = null;

   try {
      result = operation();   
   } catch(err) {
       error = err;
   }

   return [error, result];
};

const attemptAsync = async (operation) => {
   let result = null;
   let error = null;

   try {
      result = await operation();   
   } catch(err) {
       error = err;
   }

   return [error, result];
};

const [error, result] = attempt(() => {
      // ...
});

// or

const [error, result] = attemptAsync(async () => {
      // ... await something
});

See #24

arthurfiorette commented 2 months ago

What makes you think proposals are only for currently unachievable things?

bmeck commented 2 months ago

I rather like the idea of leaning more towards expression rather than assignment position so that you could do things like:

function cb(result: SafeResult);
cb(try ! JSON.parse("NOT JSON"))

not really commenting on the actual syntax but any syntax that allows that position would be nice. Also being in an expression position it would allow things like utilities ala ().unwrap_or_else one problem with prefix based operation is chaining will require () to disambiguate ordering for things if helpers are provided.

arthurfiorette commented 2 months ago

try won.

image

image

rafageist commented 2 months ago

@arthurfiorette

What makes you think proposals are only for currently unachievable things?

The purpose of proposals is not limited to addressing things that are currently unachievable. Proposals in programming languages are valuable for exploring enhancements, but they should add significant benefits without increasing complexity or redundancy. While the ?= operator and the try keyword aim to streamline error handling, it's important to assess whether they truly improve JavaScript, which already has robust mechanisms for managing errors explicitly.

Other languages, like Go and Rust, handle errors with specific patterns deeply integrated into their design. For example, Go returns a tuple with a result and an error that must be explicitly managed. But this only means that a function can return a tuple:

result, err := divide(10, 0)
if err != nil {
    fmt.Println("Error:", err)
} else {
    fmt.Println("Result:", result)
}

Rust uses the Result type and ? operator, leveraging its strong type system. But in the context of Rust, the ? operator does not handle errors in the traditional sense (as a try-catch block would in other languages), but is instead a concise way to check the value of an expression and act accordingly.

let result = divide(10.0, 0.0)?;

These languages are compiled and have different execution models from JavaScript, which is interpreted and dynamic. Introducing similar features in JavaScript could lead to a mismatch with the language's design and add unnecessary complexity. It's essential to evaluate if these changes align with JavaScript's core principles, rather than adopting features simply because they work well in other contexts.

arthurfiorette commented 2 months ago

What in "transforming the error as value" says it can only be achieved with strong typed languages? I'm not trying to port Rust's Result into a JS class...

That's just a better way to the callee of a function be explict about its possibility of an error being thrown as well as a easier way to handle them without the try {} block scoping which has no actual use besides forcing us to either indent or declare reassignable variables.

zaygraveyard commented 2 months ago

@arthurfiorette Are you saying this proposal's motivation is to avoid indenting code or using re-assignable variables? If that's the case than the Write-Once const Declarations proposal would solve solve that problem, as I mentioned in #31. Otherwise can you please clarify the problem this proposal is trying to solve (with examples)? Is it just syntactic sugar to avoid writing try-catch blocks?

rafageist commented 2 months ago

@arthurfiorette

What in "transforming the error as value" says it can only be achieved with strong typed languages? I'm not trying to port Rust's Result into a JS class...

That's just a better way to the callee of a function be explict about its possibility of an error being thrown as well as a easier way to handle them without the try {} block scoping which has no actual use besides forcing us to either indent or declare reassignable variables.

Thank you for explaining your perspective. I understand that your proposal isn't about porting features from strongly-typed languages, but rather simplifying error handling in JavaScript.

However, while transforming errors into values can work in dynamic languages like JavaScript, it risks losing the clarity that try-catch blocks provide. These blocks, though sometimes verbose, make error handling explicit and easy to follow, which is crucial in a language like JavaScript.

It's also important to remember that functions are generally expected to return specific types of data as their normal operation. Exceptions, on the other hand, are meant for exceptional cases and should not be part of the standard return value of a function. They serve to interrupt the normal flow when something goes wrong, rather than being integrated into the functionā€™s output.

Your idea to reduce indentation and the need for re-assignable variables is interesting, but we should consider whether this trade-off might complicate the code in other ways. The goal should be to enhance the language while maintaining clear and robust error handling practices.

I appreciate the effort to improve JavaScript, and I'm open to further discussion on how best to achieve that. And keep in mind, TC39 will likely be as rigorous, if not more, when evaluating your proposal.

rafageist commented 2 months ago

... 4 years ago

https://github.com/dead-claudia/proposal-try-expression

SychevD commented 2 months ago

What about ??=? How would we do something like

let a = undefined;
a ??= fn();

with ?=? Would it be ???=? a ??= try fn() looks better than a ???= fn().

UPD: Question is how the two syntaxes coexist?

nektro commented 2 months ago

??= already exists as nullish-coallescing assignment

anacierdem commented 2 months ago

I think youā€™re talking about the same thing. Question is how the two syntaxes coexist?

rafageist commented 2 months ago

What about ??=? How would we do something like

let a = undefined;
a ??= fn();

with ?=? Would it be ???=? a ??= try fn() looks better than a ???= fn().

UPD: Question is how the two syntaxes coexist?

That's one of the problems with introducing an operator. If the problem is a matter of language syntax, or flow control, or semantics, an operator is not necessarily the solution; in fact, it probably isn't the solution. It's no coincidence that people here liked the try variant.

SYip commented 1 month ago

#4 may look too close to conditional operator.

const [error, data] = mightFail() ?
  "1":
  "2";

Please dont' forget ASI in JS.

y-nk commented 1 month ago

lol, poll should consider "none of the above" option. this is enforcing your views rather than asking people's opinions.

lacherogwu commented 1 month ago

This is a nice pattern that I like. I have implemented a clean library to achieve it; you can check it out here: https://github.com/lacherogwu/safe-try

juanrgm commented 1 month ago

This syntax is better and allows all cases. The operator syntax is very limited.

const data = try await fetch("http://localhost");
const error = catch await fetch("http://localhost");
const [data, error] = trycatch await fetch("http://localhost");
const data = try JSON.parse("{}");
const error = catch JSON.parse("{}");
const [data, error] = trycatch JSON.parse("{}");

The problem with returning an array is type checking with TypeScript, you can't check if data is defined using the error value.

const process = () => "text"
const [data, error] = trycatch process();
if (!error && typeof result.data === "string") console.log(data.length);

Using objects, problems disappear.

const result = trycatch process(); 
if (!result.error) console.log(data.length);

But it is a longer syntax...

const { data } = trycatch process();
const { error } = trycatch process();
const { error: processError } = trycatch process();
const { data: processData } = trycatch process();
pedro-gilmora commented 1 month ago

2. try (as throw)

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

What about this approach with just a single catcher variable:

const result: Result | null = try mightFail();

Even forcing to collecting the error using! at the end of the expression:

const result: Result | Error = try mightFail()!;

Indicating fallback expression

const result: Result = try mightFail() ?? getDefault();
//or
const result: Result | false = try mightFail() ?? false;

Or three of them

const result: Result | Error | false = try mightFail()! ?? false;

https://astexplorer.net/#/gist/0d91e7fd84d317937241e5037f88215e/76de195ed8f520534b75a16b3d7052e7fccf6fc5

arthurfiorette commented 1 month ago

Nice idea!

Although adding a ! at the end was very criticized and makes handling the error much more forgettable.

Also, Result | Error will be very hard to distinguish between error and result

pedro-gilmora commented 1 month ago

It doesn't make sense to split them into 2 variables. Instead you can check the typeof or instance.

const result: Result | Error = try mightFail() ?? false; 

if(result instanceof Error) handleError(error);

return result;

My approach it's just to collect the a result from try-catch.

It might be easier combined with future pattern matching in Javascript> (in my mind... šŸ˜)

return match(try await mightFail())
{
  instanceof Error -> handleError,
  _ -> render
}
ljharb commented 1 month ago

@pedro-gilmora that doesnā€™t work, because you can throw any value, including null or undefined, so instanceof (which isnā€™t a reliable check anyways) doesnā€™t help you.

pedro-gilmora commented 1 month ago

@pedro-gilmora that doesnā€™t work, because you can throw any value, including null or undefined, so instanceof (which isnā€™t a reliable check anyways) doesnā€™t help you.

Knowing the nature of the "error", or rejections in promises case, it's up to the dev in the same way to handle it in the proper way. I see the same meaning of response handling unless you can inlight me.

I would comprehend your point of you were in a type-safe lang where the object strictly should meet the type matching requirement. I see it like that. I work with C# and I wouldn't propose such thing since there are not type union. Otherwise I would propose the same thing. I believe more in the reference-hold nature rather than splitting responses in order to make "readable" the code whereas JS already serves as pseudo type-union supporter thanks to Typescript.

otaxhu commented 1 month ago

@rockedpanda proposed in #45 this new pattern for safe assignment:

Promise.prototype.result = function() {
  return this.then(x=>[null,x], err=>[err,null]);
};

For synchronous functions, a polyfill like this should work:

Function.prototype.result = function (...args) {
  try {
    const ret = this.apply(this, args);

    return [null, ret];
  } catch (err) {
    return [err];
  }
};

If you liked to get a result tuple, then you would call it like this:

const [error, jsonObj] = JSON.parse.result('{"key": "value"}');
otaxhu commented 1 month ago

Enhanced polyfill to handle synchronous functions that returns Promises:

Function.prototype.result = function (...args) {
  try {
    const ret = this.apply(this, args);

    if (ret instanceof Promise) {
      return ret.then(val => [null, val], err => [err]); // return Promise, always resolves to result tuple [error, value]
    }

    return [null, ret];
  } catch (err) {
    return [err];
  }
};

then if you liked to get a result tuple, you can call a asynchronous function like this:

const [error, res] = await fetch.result("https://example.org");
ljharb commented 1 month ago

It is very intentional that you can't synchronously get at the value of a promise, so result isn't viable on Promise, and it does not make sense to add anything to functions.

otaxhu commented 1 month ago

That is why I'm not getting the value :), just wrapping the value in a result tuple inside .then callback and returning the promise. You still have to await the promise after calling fetch.result(...)

rafageist commented 1 month ago

@otaxhu And that is the same as a wrapper function. And that's correct because you use control structures and abstract the try catch.

The problem with this proposal is that try to solve a control flow problem, with an assignment of values. Assignment is one-way. Branches like throw are two-way, i.e. two distinct blocks of code, or a jump of instructions.

try
{
  proposalSafeAssignmentOperator();
}
catch (e) {
   console.log(e); // ConceptError
}

In fact, any function you make that abstracts away from the try-catch at one point, will be because there is a try-catch at another point. So a safe word to assign is to give the interpreter the job of branching behind the scenes.

[!IMPORTANT] The conceptual error I'm pointing out is precisely that flow control needs to be explicit and separate from assignment, since an assignment by definition does not have multiple paths. Trying to combine both in one operation is like trying to bend a straight line: conceptually they are incompatible.

If you want to improve error catching, or make it more readable you have to improve the language to improve flow control.

If you look at Rust and Go you will see that they are not safe assignments but a kind of implicit branching, because you are always forced to consider both possibilities (success or failure). This branching is managed by the type system (in Rust) or by error handling conventions (in Go).

Back to JS, the following code works right now:

const mightFail = () => 123;

try { var result = mightFail() } catch (e) { var error = e }

console.log(result); // 123
console.log(error); // undefined

Or:

const mightFail = () => { throw new Error ('Failed!') };

try { var result = mightFail() } catch (e) { var error = e }

console.log(result); // undefined
console.log(error); // Error

In the end, the idea is to improve the catch. That's why I proposed something that corresponded with the current language and its principles (as part of a much larger proposal):

{ var result = mightFail() } catch (error);

console.log(result);
console.log(error);

And you would get the same result. Simple to distinguish between error and result. Or, if you want you can ignore the error:

{  var jsonObj = JSON.parse('{"key": "value"}') } catch;

if (!jsonObj) console.log("It is not a valid JSON");

And with this variant you can do any throw of any value or any kind of if.

ayonli commented 1 month ago

This is good:

const [err, res] = try foo()

const [err, res] = await try bar()

And this is bad:

const [err, res] = try await bar()

Because, consider this:

const [err, res] = try (await bar())

This should have the same effect as the above, but the proposal suggests not. In JS, await bar() is already an expression that returns the result of the promise, let's not change its behavior.

A polyfill

function _try(fn: any, ...args: any[]) {
    if (typeof fn === "function") {
        try {
            return _try((fn as (...args: any[]) => any).apply(void 0, args));
        } catch (err) {
            return [err, undefined];
        }
    }

    let returns = fn;

    if (typeof returns?.then === "function") {
        return Promise.resolve(returns)
            .then((value: any) => [null, value])
            .catch((err: unknown) => [err, undefined]);
    } else {
        return [null, returns];
    }
}

const [err, res] = try foo(1, 2)
// becomes
const [err, res] = _try(foo, 1, 2)

const [err, res] = await try bar(1, 2)
// becomes
const [err, res] = await _try(bar, 1, 2)
// or
const [err, res] = await _try(bar(1, 2))

I've been using this polyfill for a long time, it's within my JavaScript extension library JsExt as well.

fabiancook commented 1 month ago

await try bar() seems obvious when seeing it for async functions.

I often do something similar to, where undefined is my "error" state thats ignored:

const value = await bar().catch(() => undefined)
if (value?.something) {
   // ...
}

This feels close to that, this would then allow you to store the promise and not yet await for the result:

const promise = try bar()

which becomes

const promise = _try(bar)

or

const promise = bar().then(result => [undefined, result]).catch(reason => [reason])

then, without need for try syntax elsewhere:

const [reason, result] = await promise;

Noting this would be a very specific case that would then be valid separately

try (await bar())()

where bar is

async function bar() {
  return function foo() {
    throw "reason";
  }
}
10hendersonm commented 1 month ago

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);
}

~= is "not equals" in LUA šŸ¤£

arthurfiorette commented 1 month ago

And this is bad:

await using was chosen based on popularity, the same we might be able to assume for try await or await try. try await feels more natural to: "try and await"

ayonli commented 1 month ago

And this is bad:

await using was chosen based on popularity, the same we might be able to assume for try await or await try. try await feels more natural to: "try and await"

await using is a statement and doesn't change the behavior of await value expression, but try await, according to this proposal, will.

In JavaScript, expressions execute in the order of right to left, let's not forget about that.

rjgotten commented 2 weeks ago

@ljharb because you can throw any value, including null or undefined

Which coincidentally also means that an error-value tuple used in the following manner:

const [error, value] = try await mightFail();
if (error) { /* ... */ }

is inherently insufficient and flawed.

You can throw anything - including null or undefined. If the result is [undefined, undefined] was it an error? Or was it a success?

The original try expressions proposal handled this better by returning an object with an explicit caught boolean. E.g.

const { caught, error, value } = try await mightFail();
if (caught) { /* ... */ }

@fabiancook await try bar() seems obvious when seeing it for async functions.

No. Expressions go right-to-left, so it should be try await bar() - i.e. try (await bar()). You're awaiting the completion of the promise returned by the async bar function and the result of that expression is something you want to wrap in a try expression, such that it maps to an error-value tuple -- or rather; a caught-error-value object -- to cover where the await operation throws.

rafageist commented 2 weeks ago

@rjgotten Right! From left to right:

const res = await mightFail() catch err;

[!IMPORTANT] Results are expected; errors are caught.

So

const res = await mightFail(); // no catch
const res = await mightFail() catch; // ignore errors
const res = await mightFail() catch err; // catch error
const res = { await mightFail() } catch err; // catch error inside block
alexanderhorner commented 2 weeks ago

@rjgotten Right! From left to right:


const res = await mightFail() catch err;

[!IMPORTANT]

Results are expected; errors are caught.

So


const res = await mightFail(); // no catch

const res = await mightFail() catch; // ignore errors

const res = await mightFail() catch err; // catch error

const res = { await mightFail() } catch err; // catch error inside block

You've missed the point of this proposal