Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Question: Why Symbol.result in addition to throw? #5

Open
garretmh opened this issue Aug 15, 2024 · 21 comments
Open

Question: Why Symbol.result in addition to throw? #5

garretmh opened this issue Aug 15, 2024 · 21 comments
Labels
enhancement New feature or request

Comments

@garretmh
Copy link

garretmh commented Aug 15, 2024

Most of the language of this proposal is centered around improving try/catch ergonomics which sounds great! As such, I'm curious why this adds so much additional behavior focused around Symbol.result.

My naïve expectation for this sort of language feature would be to literally extend try to accept an expression and return a result tuple/object representing either the resulting or thrown value of the expression.

Example:

const [error, value] = try null.foo
// error is TypeError: Cannot read property 'foo' of null

I assume there's a good reason this proposal opted for adding a new error handling paradigm instead so I'd like to hear more about that.

@arthurfiorette
Copy link
Owner

That's how mostly of our new features are actually introduced into JS.

And a lot of other examples...

Basically, every new syntax is backed up by something set in the prototype.

@baileympearson
Copy link

baileympearson commented Aug 15, 2024

I think this is different than other proposals that add new symbol properties. Not every new syntax involves symbols - ?? and ??= are two examples. Async dispose and iterators are making objects usable in certain contexts - when an object implements Symbol.iterator, it makes itself iterable and when an object implements Symbol.dispose/asyncDispose, it makes itself disposable. But I don't understand the need to make an object "resultable" (what does that even mean conceptually?).

It seems like a try-expression would be a nice, ergonomic solution unless there are issues that make it infeasible.

@anacierdem
Copy link

Would it be worth it to add this to the language where we can already do:

const [error, value] = try(() => null.foo);

The benefit is just marginal for legitimizing a very questionable pattern.

@jamiebuilds
Copy link

jamiebuilds commented Aug 16, 2024

I don't know if any other current or former members of TC39 have commented anywhere on this yet, but the symbol interface is going to be a non-starter for this proposal. There's nothing gained by having it, and it makes the proposal substantially less useful.

I would drop that and make this a simple syntactic sugar if you'd like it to go anywhere.

let [error, result] ?= expression

// is just syntactic sugar for:

let $0
try {
  $0 = [void 0, expression]
} catch ($1) {
  $0 = [$1, void 0]
}
let [error, result] = $0

(This is not an endorsement of the overall idea here though, I personally would not like to add Golang style error returns to JavaScript in any form, in favor of other proposals such as try-catch-expressions)

@guillaumebrunerie
Copy link

Another drawback of Symbol.result is that there is no guarantee that

const [, data] ?= f();

and

const data = f();

give the same output (assuming there is no error/exception), it could technically do two completely different things. The fact that it is even possible sounds like a terrible idea for something that should just be syntactic sugar around try catch.

@ljharb
Copy link

ljharb commented Aug 21, 2024

to be clear, that's not just a drawback, that's the reason that a symbol for this will literally never fly in committee. It should just be removed.

@anacierdem
Copy link

This is effectively an overload for the call operaror () which indeed expands the scope a lot.

@khaosdoctor
Copy link

khaosdoctor commented Aug 22, 2024

Another problem with symbols in this case is: How do we know a function implements Symbol.result? How do we know it can be used with what we're calling a Result tuple or with a try/catch, I think someone mentioned this in #12 but in general, if there's no Symbol.result JS would thrown a type error? Or would it just crash because it couldn't find the Symbol? In the later case it could be even more cryptic as the error will be something like Cannot read [Symbol.result] of something.

This is potentially solved in #5 (comment) using #4 if all this becomes a sugar for try, which I'd love to see

@otaxhu
Copy link

otaxhu commented Sep 3, 2024

following @jamiebuilds example.

The try-expression can be parsed easily to this:

// try-expression
const [error, value] = try expression;

// can be parsed to:
let result;
try {
  result = [null, expression];
} catch (e) {
  result = [e];
}

const [error, value] = result;

Some examples:

// try-expression with await promise
const [error, value] = try await Promise.resolve("hello!");

// can be parsed to:
let result;
try {
  result = [null, await Promise.resolve("hello!")];
} catch (e) {
  result = [e];
}
const [error, value] = result;
// try-expression with await no promise
const [error, value] = try await "not a promise";

// can be parsed to:
let result;
try {
  result = [null, await "not a promise"];
} catch(e) {
  result = [e];
}
const [error, value] = result;
// try-expression unassigned
try "expression";

// can be parsed to:
try {
  "expression"; // NOTE: expression may produce side effects, still execute expression
} catch {}
// try-expression of an object (this needs some strict syntax, for not be confused with try statement)
const [error, value] = try ({property: "hello!"}); // enclosed with parentheses

// can be parsed to:
let result;
try {
  result = [null, ({property: "hello!"})];
} catch (e) {
  result = [e];
}
const [error, value] = result;

There is no need for @@result symbol nor modify the object's prototypes nor introduce a new operator.

That is my opinion :)

I really like the work of this proposal, and I would like to see this feature in the soon future.

This would also solve this issue #39 (await type safety) by allowing any expression be used in the "try-expression"

EDIT: Some problems with not assigned try-expressions

A not assigned try-expression will have the problem that the error will be completely dropped. Leading to undesired behaviour (or not).

In Go error handling, the error and the value(s) can be unassigned (and that is good, I guess).

@otaxhu
Copy link

otaxhu commented Sep 3, 2024

The allowance of @@result in any user-defined object is harmful, because the user can define a function that may throw and modify the Function @@result method prototype to not catch the error and not create the result tuple, crashing the whole application.

Example:

// malicious program/library/framework/etc.
Function.prototype[Symbol.result] = function() {/* does nothing */}

function libFunc() {
  throw "You have been crashed :P";
}

// user program
const [error, value] ?= libFunc(); // Does not create the tuple, instead program crashes

@otaxhu
Copy link

otaxhu commented Sep 4, 2024

Try expression proposed syntax:

UnaryExpression ::
    TryExpression :: `try` [not `{` character] UnaryExpression

TryExpression would become a UnaryExpression, just like AwaitExpression and similars.

This would have a drawback and that is it can be misused by chaining TryExpression unary expressions. Although AwaitExpression also have this drawback and it went to the Standard.

Example:

const [error, value] = try try func();

// reinterpreted as this:
const [error, value] = try (try func());

// would be parsed to:
let result;
try {
  let result2;
  try {
    result2 = [null, func()];
  } catch (e) {
    result2 = [e];
  }
  result = [null, result2]; // result2 is a expression that will never throw
} catch (e) {
  // UNREACHABLE
  result = [e];
}

const [error, value] = result; // error === null and value === TupleNotActualValue

If func() throws, error value won't be populated with error, because inner try wraps it in a tuple expression that we know will never throw.

As I said AwaitExpression also have this drawback, it can also be chained

await await await 123; // produces 123

@khaosdoctor
Copy link

khaosdoctor commented Sep 4, 2024

I think we all agree that @@result is not a good idea.

Regarding this:

EDIT: Some problems with not assigned try-expressions
A not assigned try-expression will have the problem that the error will be completely dropped. Leading to undesired behaviour (or not).

In Go error handling, the error and the value(s) can be unassigned (and that is good, I guess).

I think one option is to not allow not assigned try expressions. This could either be an eslint rule (which I think it's the best case) or a compiler error where tryExpression must appear after an assignment

@jamiebuilds
Copy link

@otaxhu:

following @jamiebuilds example.
The try-expression can be parsed easily to this

To be clear, by "Try Expressions" I was not suggesting a "Try Operator":

  • Try Operator:
    • let [error, result] = try <expression>
    • This is a prefixed unary operator, similar to typeof <expression>, and is unrelated to JavaScript's existing try statements
  • Try Expression:
    • let result = try {<do-block>} catch (error) {<do-block>} finally {<do-block>}
    • This builds upon the do expressions proposal
    • This syntactically mirrors the existing syntax of try statements and would be kept in sync with them

Do Expressions

If you're unfamiliar, this has been a TC39 proposal for a long time, I believe it has a lot of support, but it's been stuck for awhile on implicitly returning object literals (and maybe some other things)

let sum = do {
  let augend = 40
  let addend = 2
  augend + addend // implicit "return"
}
console.log(sum) // >> 42

Try Operator vs Try Expression Comparison

Syntax

// Try Operator
let [error, result] = try await fetch(url).then(res => res.json())
if (error != null) {
  window.reportError(error)
  result = 42
}

// Try Expression
let result = try {
  await fetch(url).then(res => res.json())
} catch (error) {
  window.reportError(error)
  42
}

Try Operator:

  • Typing: More typing (example above is 131 chars)
  • Formatting: Body is expression, forces one giant expression that wraps lines
  • Paradigm: Inventing new error handling style
  • Static Analysis: Forces tools to track types or data flow to find error handling code
    • (Note: There's no guarantee that try is immediately destructured or how the presence of an error might be checked)
  • Future: Won't benefit from any other improvements to try-catch, like pattern matching
    • (Note: A separate match expression won't automatically throw unmatched errors)
  • Finalizer: Doesn't have a direct parallel to finally branch, you can either shove it in between the let [error, res] and if (error != null) or have even more new syntax like Golang's defer or restructure your code to use the using proposal
    • (Note: Go's defer is not a direct parallel to finally because it waits for the function to return, so it's inventing even more new things and doesn't cover every use case for finally)
    • (Note: Go's defer probably wouldn't work well in JS because of how async works in JS)
  • Overhead: Always has the overhead of having to construct an array or some sort of iterable to be destructured, which unfortunately can't just be optimized away completely by the engine)
  • Variable pollution: JavaScript can't keep redefining err the way that Golang can, so each error would have to be named something different

Try Expression:

  • Typing: Less typing (example above is 114 chars)
  • Formatting: Body is block, can be broken out across statements
  • Paradigm: Builds off existing error handling style
  • Static Analysis: Has static syntax for error handling code
  • Future: Will benefit from any future improvements to try-catch, like pattern matching
  • Finalizer: Keep using the finally you already know (See example below)
  • Overhead: No new overhead
  • Variable pollution: Error bindings are scoped, no issue here

Finalizer Comparison

Try Operator

You need to do it specifically in this order, which flips the order of what people are used to in JavaScript. And you now need extra code to ensure the error is re-thrown to match the way that try-finally works.

let [error, result] = try (db.open(), db.query("SELECT RANDOM"))
db.close() 
if (error != null) {
  throw error
}

Try Expression

Not really anything new here.

let result = try {
  db.open()
  db.query("SELECT RANDOM()")
} finally {
  db.close()
}

imo

For the above reasons, I strongly support "Try Expressions" and I don't support a "Try Operator" (including other forms like a new assignment operator or other words for try)

I haven't put a ton of thought into it (I'm not sure of all it's implications on parsing), but I might support building upon try statements (and thus expressions) to support the body as an expression:

// try statement, with body expression
let result
try result = doSomething() catch (error) {
  // ...
}

// try expression, with body expression
let result = try doSomething() catch (error) {
  // ...
}

@yeliex
Copy link

yeliex commented Sep 26, 2024

I have created an npm package to handle this, using a function wrapper instead of the new operator, so it is backward complicated.

https://www.npmjs.com/package/safe-assignment

@DScheglov
Copy link

I have created an npm package to handle this, using a function wrapper instead of the new operator, so it is backward complicated.

https://www.npmjs.com/package/safe-assignment

What if null is thrown? ))

I've also written the similar library )
do-try-tuple

@yeliex
Copy link

yeliex commented Sep 27, 2024

I have created an npm package to handle this, using a function wrapper instead of the new operator, so it is backward complicated.
https://www.npmjs.com/package/safe-assignment

What if null is thrown? ))

I've also written the similar library ) do-try-tuple

safeTry<null>().

when throw null, consider null as a special error type

@DScheglov
Copy link

@yeliex

safeTry().

when throw null, consider null as a special error type

It is not a good idea to presume the error type before it has been caught, please consider:
https://www.typescriptlang.org/tsconfig/#useUnknownInCatchVariables

In the when you run only your own code and you are confident that only null
can be thrown, you can presume the error type, but even in this case further
error handling looks ... a little bit unexpectedly:

const [error, value] = safeTry<null>(runOnlyOwnCode);

if (error === null) {
   console.error("The error has been thrown");
   throw new Error('Real error');
}

// process with value here

The handling of nulish and falsy errors is one of the issues of the safe-assignment and try-expression.
The libraries can solve the issue in any convenient for there consumers way, but declare this way.

@yeliex
Copy link

yeliex commented Sep 27, 2024

@yeliex

safeTry().
when throw null, consider null as a special error type

It is not a good idea to presume the error type before it has been caught, please consider: https://www.typescriptlang.org/tsconfig/#useUnknownInCatchVariables

In the when you run only your own code and you are confident that only null can be thrown, you can presume the error type, but even in this case further error handling looks ... a little bit unexpectedly:

const [error, value] = safeTry<null>(runOnlyOwnCode);

if (error === null) {
   console.error("The error has been thrown");
   throw new Error('Real error');
}

// process with value here

The handling of nulish and falsy errors is one of the issues of the safe-assignment and try-expression. The libraries can solve the issue in any convenient for there consumers way, but declare this way.

@DScheglov
as default, the Error generic type is unknown to assign with typescript, it should cover the null type which you mentiond last commit. If make sure the error type is null, use generic.

@DScheglov
Copy link

@yeliex

@DScheglov
as default, the Error generic type is unknown to assign with typescript, it should cover the null type which you mentiond last commit. If make sure the error type is null, use generic.

The problem is not in the declared type of error. The declaration of this type -- is a problem.

We cannot expect that only expected type exceptions will be thrown. That's why typescript goes to unknown in the catch block

@yeliex
Copy link

yeliex commented Sep 27, 2024

@DScheglov so the default generic Error type in my package is unknown, aligned with typescript, but you could also define the error type(which to respond to your problem What if null is thrown? above. if you want to throw null, and if it is clear, you can generic the Error as null of course. but it is not sure the thrown error type, type detect is necessary, just as the common typescript catch did.)

if(!error) {...} in my code is just an example, to simplify, if necessary, you can also detect if it is null.

@DScheglov
Copy link

@yeliex

let's guess you call some function. How do you know what type of exception it can throw?
It is a question?

Ok, you can look at the code of the function you explicitly call. But this function can call another one, the last one calls the third one, and so on.
In one moment you meet the place when you see that some function is called, but which one could be defined in runtime only.

Before answering on my question, please read the following comment.

We've started discusion of your library, so it is better to move this discussion to the correspondent repo.
I've created an issue there: yeliex/safe-assignment#1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests