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

these are statements, not expressions #54

Closed
2 tasks done
ljharb opened this issue Jan 20, 2025 · 29 comments
Closed
2 tasks done

these are statements, not expressions #54

ljharb opened this issue Jan 20, 2025 · 29 comments

Comments

@ljharb
Copy link

ljharb commented Jan 20, 2025

Prerequisites

  • I have written a descriptive issue title
  • I have searched existing issues to ensure the bug has not already been reported

Versions

https://github.com/arthurfiorette/proposal-try-expressions?tab=readme-ov-file#rules-for-try-expressions item 1 says they can't be inlined, like return or throw - that makes them statements. If they were expressions, they'd be usable in any context expressions are.

A minimal reproducible example

n/a

Description

n/a

Steps to Reproduce

n/a

Expected Behavior

n/a

@arthurfiorette
Copy link
Owner

You're right! I guess i'm renaming this once again to try-statements :0

@ljharb
Copy link
Author

ljharb commented Jan 20, 2025

Why not let them be expressions, though?

@arthurfiorette
Copy link
Owner

arthurfiorette commented Jan 20, 2025

Their usage don't make sense.

const result = try throw new Error()
// why not just:
const error = new Error()
const result = try using x = something()
// This might make sense to catch a possible error 
// of something() throwing before returning a resource
// but wouldn't handle any resource disposal thrown error.

...


How would this resolve into a Result?

const result = try {
  statementOne();
  statementTwo();
}

the above is more related to proposal-do-expressions than to what this aims to solve. Once it's approved we will be able to use it like:

const result = try do {
  work()
}

Since do will evaluate into an expression and try works with expressions.

@ljharb
Copy link
Author

ljharb commented Jan 20, 2025

because the usage wouldn't ever be try throw new Error(), it'd be try somethingThatMightThrow().

@arthurfiorette
Copy link
Owner

I think we are saying the same thing: Try is a statement that is followed by an expression. try something() is valid usage.

https://github.com/arthurfiorette/proposal-try-statements?tab=readme-ov-file#void-operations

@ljharb
Copy link
Author

ljharb commented Jan 20, 2025

I'm suggesting that try <expression> itself be an expression - there's no reason I can see for it not to be.

@DScheglov
Copy link
Contributor

The main difference between a statement and an expression is that an expression represents a value that can be used in other expressions and statements, whereas a statement does not.

A statement performs a side effect or imperatively alters the flow of execution and may return a value from the function scope (e.g., if, for, etc.). For example, try { } catch { } is a statement.

If a proposal defines try as a statement, we cannot do the following:

const x = try fn();

Conversely, if we can write this, then try fn() must be an expression.

I also see no reason why try couldn't be inlined.

I'd like to point that we cannot do something like that:

const y = while(x < 9) x++;

@DScheglov
Copy link
Contributor

@arthurfiorette

const error = try throw new Error('Some Error');

must be treated as a syntax error because throw is not an expression yet.

Actually, according to the desugaring the try throw new ... is equivalent to

let _result;
try {
  _result = Result.ok(throw new Error('Some Error');
} catch(err) {
  _result = Result.error(error);
}
const result = _result;

But it is not allowed by JS, because throw is a statement.

@DScheglov
Copy link
Contributor

DScheglov commented Jan 21, 2025

It seems I got the initial concern to avoid inlining.

@arthurfiorette , could you please correct me if I am wrong here.

Let's consider the following case:

const result = condition
  ? try doSomething()
  : Result.ok(10);

Current desugaring with try { ... } catch () { ... } cannot cover this case correctly.

So we need to consider replacing desugaring with IEFE, but it will require different desugarring for try <expr> and try await <expr>:

The example above could be desugarred to:

const result = condition
  ? (() => { try { return Result.ok(doSomething()); } catch (error) { return Result.error(error); } })() 
  : Result.ok(10)

Note: We need to use exactly arrow function expression to keep this context untouched

The async case:

const result = condition
  ? try await doSomething()
  : Result.ok(10);

could be desugarred into:

const result = condition
  ? await (() => {
      try {
        const _r = doSomething();
        return isPromiseLike(_r) ? _r.then(Result.ok, Result.error) : Result.ok(_r);
      } catch (error) {
        return Result.error(error);
      }
    })()
  : Result.ok(10);

So, @ljharb, @arthurfiorette what do you think should we update desugaring approach?

@ljharb
Copy link
Author

ljharb commented Jan 21, 2025

const result = condition
  ? try doSomething()
  : Result.ok(10);

would be more like:

let _result;
if (condition) {
  try {
    _result = Result.ok(doSomething());
  } catch (e) {
    _result = Result.error(e);
  }
} else {
  _result = Result.ok(10);
}
const result = _result;

there shouldn't need to be any new function scopes involved.

@DScheglov
Copy link
Contributor

@ljharb

Your approach does actually mean, that inlining is not possible, and each case of try <expr> must be considered separately.

For example:

const result = value || try doSomething(); // or the same with &&
const result = fn?.(try doSomething());

@ljharb
Copy link
Author

ljharb commented Jan 21, 2025

ah, true, there will definitely be some situations where my approach won't work.

Either way tho, inlining is possible regardless - we're talking about the transpilation/desugaring, which isn't part of the proposal.

@DScheglov
Copy link
Contributor

we're talking about the transpilation/desugaring, which isn't part of the proposal.

Exactly )

By the way, what do you think about the following proposal: consider Result.error() as false value, to get able to write something like that:

const result = (try doFirst()) || (try doSecond());

(Sorry, for offtop)

@ljharb
Copy link
Author

ljharb commented Jan 21, 2025

It's not possible to have a falsy object.

@DScheglov
Copy link
Contributor

I know ) ... But we can just propose )))

Actually, we can consider something like Result static methods: Result.firstOk(...), Result.firstError() and Result.collect():

{
  const result = Result.firstOk(
    try doSomething(),
    try doSomethingElse(),
  ); // returns result of doSomething if it's ok is true, otherwise returns result of doSomethingElse
}

{
  const result = Result.firstError(
    try doSomething(),
    try doSomethingElse(),
  ); // returns result of doSomething if it's ok is false, otherwise returns result of doSomethingElse
}

{
  const results = Result.collect(
    try doSomething(),
    try doSomethingElse(),
  ); // returns Result that value is an array of successful results, and the first error met
}

@ljharb
Copy link
Author

ljharb commented Jan 21, 2025

Proposing something that's a nonstarter would be counterproductive.

@DScheglov
Copy link
Contributor

But what about static methods?
Should I create a separate issue?
The same story with Function and Monad instance methods?

@ljharb
Copy link
Author

ljharb commented Jan 21, 2025

I think that the proposal would need to make a compelling case that there's a problem to solve, and that static methods are a good way to solve that problem.

@Arlen22
Copy link
Collaborator

Arlen22 commented Jan 21, 2025

Their usage don't make sense.

const result = try throw new Error()
// why not just:
const error = new Error()
const result = try using x = something()
// This might make sense to catch a possible error 
// of something() throwing before returning a resource
// but wouldn't handle any resource disposal thrown error.

How would this resolve into a Result?

const result = try {
  statementOne();
  statementTwo();
}

the above is more related to proposal-do-expressions than to what this aims to solve. Once it's approved we will be able to use it like:

const result = try do {
  work()
}

Since do will evaluate into an expression and try works with expressions.

@arthurfiorette I don't understand your arguments here. Rule 1 of your proposal (can't be inlined) gives the following code snippet. I think the OP is asking to make this code snippet valid syntax, not the examples you listed above.

array.map((fn) => try fn()).filter((result) => result.ok) // Syntax error!

@arthurfiorette
Copy link
Owner

I understood it wrong and mistakenly renamed to statements. I was wrong and don't think limiting its usage is a good idea and it should behave just like await or typeof operators.

proposal-try-operator is a good name fit?

(   try       something())
(<operator>  <expression>)

// above evaluates into a `Result` iterable object. 

This is all. Everything else is just the above concepts applied differently:

const a = try something()
const [[ok, err, val] = [try something()]
const [ok, err, val] = try something()
array.map(fn => try fn())
yield try something()
try yield something()
try await something()
try (a instanceof b)
(try a) instanceof Result
const a = try try try try try try 1 

Arlen22 added a commit to Arlen22/proposal-try-expression that referenced this issue Jan 23, 2025
@gideaoms
Copy link

gideaoms commented Jan 24, 2025

I like this syntax:

const [err, data] = try maybeThrow();

But with this rule:

  • err always extends the Error class. If something other than an Error instance is thrown, it simply bubbles up.

By following this approach, we don't need an ok value. Javascript has some weird behaviors, being able to throw anything like undefined, null, or '' is one of them. I think this approach fixes it.

@ljharb
Copy link
Author

ljharb commented Jan 24, 2025

That’s unacceptable; being able to throw any value isn’t broken, and non-errors aren’t somehow “discouraged” to be thrown.

@Arlen22
Copy link
Collaborator

Arlen22 commented Jan 24, 2025

The only way we could do that is if we ALWAYS wrap the error. So we'd have to created a new object with an inner property and always return it.

If you want to save on the number of variables you have two options.

let ok, error, tried;
[ok, error, tried] = try something();
if(!ok) {} // handle the error
const data = tried; // not sure how typing will work here. 
//... repeat

Or

const result = try something();
if(!result.ok) { return console.error(result.error); } // handle the error
console.log(result.value) // use the value

@DScheglov
Copy link
Contributor

@Arlen22

to be honest we have also another opportunity.
We can keep the TryResult type as is, but we can change the destructuring to tuple a little bit:

const [failure, result] = try doSomething();

if (failure) { // if nothing has been thrown and caught `failure` will be undefined
  console.log(`Error occurs: ${failure.error}`);
  throw failure.error;
}

So, it is something like to choose between more concise on the destructuring and risks to get issues with re-throwing, when the failure will be thrown instead of the failure.error. But concise also matters, and incorrect re-throwing could be handled by linters.

@arthurfiorette
Copy link
Owner

const result = try doSomething();

if (result.failure) {}

Suffers from the same problem as if was destructured. Unless we partially define a field to be used like 'error' in result but I don't like it neither in operator presents good use cases in modern js

@Arlen22
Copy link
Collaborator

Arlen22 commented Jan 24, 2025

The way I see it from my limited understanding of the proposal process and my extensive experience using Javascript and Typescript, I think we only have two options that stand any chance of being accepted. Both of these can be strongly type-checked and give solid runtime guarantees with minimal code.

  1. A three variable destructuring.
    const [ok, error, result] = try something();
    if(!ok) console.error(error);
  2. A two variable destructuring with the error always wrapped in a TryError instance.
    const [error, result] = try something();
    if(error) console.error(error.inner);

The other two options are the kinds of inconsistencies Javascript has been moving away from ever since ES5.

  1. A two variable destructuring with the error conditionally wrapped (requiring an error instance) is so annoying because it's going to require a ternary and instanceof in almost every single case. I hate this option because of how much code you have to write to figure out what's going on and get your error information. It technically works, but it's anything but clean. Requiring a truthy value would be even worse.
    const [error, result] = try something();
    if (error) { console.log((error instanceof TryError) ? error.inner : error); }

Thankfully I don't think any of you are endorsing the following, but for completeness, I present: The Worst Possible Option

  1. A two variable destructuring with the error never wrapped is the worst because there can be NO guarantees. The code may or may not have completed, and neither variable can actually tell you that for sure. There are no type-time or run-time guarantees. This seems like the type of thing JavaScript tries to prevent.
    const [error, result] = try something(); 
    // even if error is undefined, it still might have thrown. We can never guarantee anything. 
    // and result might be intentionally void anyway. 
    if (error === undefined && result === undefined) {} // who knows what happened here?
    // also, the current proposal allows this behavior if you explicitly want it
    const [, error, result] = try something();
    () => { throw; } is entirely valid JavaScript.

@ljharb
Copy link
Author

ljharb commented Jan 24, 2025

also, instanceof doesn't work across realms, and is forgeable and breakable, so anything that encourages or requires an instanceof check isn't going to fly.

@DScheglov
Copy link
Contributor

Conditional wrapping is definitely bad.
Re-throwing TryError instead of TryError.inner (or better, TryError.cause) is also risky.

I just pointed out this kind of option to ensure we don’t lose anyone useful.

@arthurfiorette
Copy link
Owner

Thanks @ljharb.

Just renamed to try-operator

https://github.com/arthurfiorette/proposal-try-operator?tab=readme-ov-file#try-operator

I'll be closing this issue.

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

No branches or pull requests

5 participants