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

Discussion: Preferred operator/keyword for safe assignment #4

Open
Not-Jayden opened this issue Aug 15, 2024 · 127 comments
Open

Discussion: Preferred operator/keyword for safe assignment #4

Not-Jayden opened this issue Aug 15, 2024 · 127 comments

Comments

@Not-Jayden
Copy link

Not-Jayden commented Aug 15, 2024

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

@addaleax
Copy link

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.

@arthurfiorette arthurfiorette pinned this issue Aug 15, 2024
@VictorCamargo
Copy link

VictorCamargo commented Aug 15, 2024

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

@Not-Jayden
Copy link
Author

Not-Jayden commented Aug 15, 2024

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

@ambroselittle
Copy link

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.

@baileympearson
Copy link

baileympearson commented Aug 15, 2024

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.

@manniL
Copy link

manniL commented Aug 15, 2024

@ThatOneCalculator just remove "results" from the url.

Voting page

@ghost
Copy link

ghost commented Aug 15, 2024

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

@baileympearson
Copy link

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

@reececomo
Copy link

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

@alexanderhorner
Copy link

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

@dominikdosoudil
Copy link

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.

@alexanderhorner
Copy link

alexanderhorner commented Aug 16, 2024

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

@alexanderhorner
Copy link

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.

@Arilas
Copy link

Arilas commented Aug 16, 2024

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.

@cawabunga
Copy link

cawabunga commented Aug 16, 2024

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
Copy link

t1mp4 commented Aug 16, 2024

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.

@dominikdosoudil
Copy link

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

@alexanderhorner
Copy link

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

@dominikdosoudil
Copy link

dominikdosoudil commented Aug 16, 2024

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.

@fabiancook
Copy link

fabiancook commented Aug 16, 2024

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

@alexanderhorner
Copy link

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.

@fabiancook
Copy link

fabiancook commented Aug 16, 2024

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

@dominikdosoudil
Copy link

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

@fabiancook
Copy link

fabiancook commented Aug 16, 2024

const settled ~= await action();
doSomethingWithResult(settled)

@dominikdosoudil
Copy link

const settled ~= await action();
doSomethingWithResult(settled)

Obviously, but keyword would allow me do it directly.

@fabiancook
Copy link

fabiancook commented Aug 16, 2024

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

@dominikdosoudil
Copy link

(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).

@alexanderhorner
Copy link

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

@fabiancook
Copy link

fabiancook commented Aug 16, 2024

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)

@rafageist
Copy link

rafageist commented Oct 16, 2024

@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
Copy link

@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

@vikingair
Copy link

For anyone who might be interested in already "testing" how it feels without waiting for any syntax changes or the proposal to continue, I've created a minimal library with necessary type safety here: https://jsr.io/@backend/safe-assignment

It takes the currently highest voted solution "try (as throw)" and translates from:

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

to

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

I'm already evaluating with it, if it feels better than using the current try-catch, and noted down some caveats.

@DScheglov
Copy link

@vikingair
I've done the same: https://www.npmjs.com/package/do-try-tuple

By the way, do you consider custom promise classes and different realms (see: isError Proposal)?

@vikingair
Copy link

@DScheglov Not yet, as soon as the proposal would be stage 3, and supported by TS, I'd likely migrate from the instanceof check. Thanks for pointing this out.

I also wasn't aware of an existing implementation. The thread here is already quite huge 😬

Looking at your implementation the only differences seem to be:

  • I've combined doTry and safe into a single wrapper withErr
  • I'd recommend to check for errors via if (err) { ... }, because I wrap anything thrown that isn't an error by an actual error. Hence, I make guarantees on the returned type for errors.

@DScheglov
Copy link

DScheglov commented Oct 21, 2024

@vikingair

Not yet, as soon as the proposal would be stage 3, and supported by TS, I'd likely migrate from the instanceof check.

The proposal addresses an existing issue: different REALMs (such as iframes and Node.js Virtual Machine modules). Additionally, I encountered a case when jest overrides the global.Error, causing instanceof Error to return a false negative. Yes, it's an edge case, but errors are always on the edge.

I also wasn't aware of an existing implementation. The thread here is already quite huge 😬

There are dozens of similar implementations )
Two most "downloaded":

  • because I wrap anything thrown that isn't an error by an actual error. Hence, I make guarantees on the returned type for errors.

Are you sure that is correct? If someone throws something that isn't an error, do they perhaps expect to catch something that isn't an error (#30 (comment))?
But if you do such transformation, it makes sense to be able detect that and get the original error.

Meaning the following code must work without breaking the code that calls it.

function fn() {
  const [error, result] = try operation();

  if (error) {
    if (error instanceof MyVerySpecificError) return null;
    throw error;
  }
  
  return 'ok';
}

Now it seems it is better to return the following tuple:
[ok: true, error: undefined, result: T] | [ok: false, error: unknown, result: undefined]

Or something like that:

[error: CaughtError, result: undefined] | [error: undegined, result: T] -- See in TS Playground

Because converting an error into something else may result in a different flow splitting behavior than with a standard catch, that can cause some errors will not be handled correctly.

@vikingair
Copy link

@DScheglov Thanks for your hints, but so far I created only custom error classes that extend the Error class. Similar to all other native error constructors, e.g. new TypeError() instanceof Error === true.

Wrapping the thrown thing into an error is mainly done to push best practices of avoiding to throw anything that isn't an error. I know it can cause issues with a lot of existing code.

My implementation is just "one" implementation. Very likely not the best or most appropriate. Shouldn't be taken as a guideline implementation for this proposal but rather to get a feeling of how it is to write JS following that syntax. Getting a feeling of it feels weird or cumbersome to write JS error handling like that.

@DScheglov
Copy link

@vikingair

Wrapping the thrown thing into an error is mainly done to push best practices of avoiding to throw anything that isn't an error. I know it can cause issues with a lot of existing code.

If something can cause the issues with existing code how it could be a best practice? :)
Well, to throw only instances of Error classes -- is a best practice.
Wrapping caught non-error value is a different thing, especially considering that also an error could be falsy treated as non-error.

In any case, if you wrap something, please ensure that someone can unwrap this something.

@bparks
Copy link

bparks commented Nov 2, 2024

I'm glad to see a lot of momentum around the "try as throw" syntax proposal (... = try doSomething()).

However, I think it's problematic to "return" a tuple in an attempt to mirror Go's syntax. Any functional language (or Rust) would be a better example, IMO feeling far more idiomatic.

Something like:

let processedResult;
const maybeResult = try doSomething();
if (maybeResult.ok) {
  processedResult = doSomethingWithResult(maybeResult.result);
} else {
  processedResult = doSomethingElseInstead(maybeResult.error);
}

I don't feel like this, by itself, has much of an advantage over a traditional try...catch, especially if/when a finally is involved, but it does open up additional possibilities that a tuple just wouldn't, like:

const processedResult = (try doSomething())
  .unwrapOrElse(doSomethingElseInstead)
  .then(doSomethingWithResult)
  .finally(...)

(Note: I used unwrapOrElse to mirror a similarly-named Rust function, but not to indicate a strong opinion on what the name should be)

To be fair, I'm not sold on introducing another meaning/purpose for then (but I can't think of a better word right now) or the encouraged return to method-chaining that async/await alleviated for Promises, but doing something like the following also feels weird (two try keywords):

try {
  const result = (try doSomething())
    .unwrapOrElse(doSomethingElseInstead);
  const processedResult = doSomethingWithResult(result);
} finally {
  //...
}

@DScheglov
Copy link

DScheglov commented Nov 2, 2024

@bparks

It's a great idea.

However, this means we also need to introduce the Result<T, E> type with a rich API—far richer than what promises offer. Alternatively, we need a way to specify a concrete type to be returned from a try-expression, allowing developers to use their own implementations.

Additionally to Result<T, E>, imho, it makes sense to introduce the Symbol.result to allow implementaion of

interface Resultable<T, E> {
  [Symbol.result](): { value: T, ok: true } | { error: E; ok: false }
}

And two operators: unwrap and unwrap! (not final naming as well) to get able to write code like this:

declare async function createUser(userData: UserData): Promise<Result<User, CreateUserError>>;

async function signUp(userData: UserData) {
  // --- snip ---
  const user = unwrap await createUser(userData);
  // assigns user with value of type User OR
  // returns Result<never, CreateUserError> (wrapped to Promise.resolve)
}

The code bases that:

class Result<T,E> implements Resultable<T, Result<never, E>> {
 // -- snip --
}

The unwrap! operators just throw the error, recieved from [Symbol.result]() call instead of the returning it

@ljharb , @arthurfiorette
what do you think about such approach?

@ljharb
Copy link

ljharb commented Nov 2, 2024

I’m not clear on why a symbol would be needed?

@DScheglov
Copy link

@ljharb

I’m not clear on why a symbol would be needed?

The Symbol.result is part of the unwrap implementation:

  1. Let unwrap <value>.
  2. If typeof value[Symbol.result] !== "function", throw a TypeError.
  3. Call value[Symbol.result]().
    1. If value[Symbol.result]() returns { ok: true, value: <okValue> }, then the result of unwrap <value> is okValue.
    2. If value[Symbol.result]() returns { ok: false, error: <errValue> }, return errValue from the current function scope.
    3. Otherwise, throw a TypeError.

The Symbol.result could be used for other types as well. For example:

type Maybe<T> = Some<T> | None;

class Some<T> implements Resultable<T, never> {
  [Symbol.result]() {
    return { ok: true, value: this.value }
  }
}

class None implements Resultable<never, None> {
  [Symbol.result]() {
    return { ok: false; error: this };
  }
}

And later the correspondent instance could be unwrapped:

declare async function getUserByName(userName: string): Promise<Maybe<User>>;

function findUser(userName: string): Promise<Maybe<Omit<User, "passwrod"> & { password?: never }>> {
  const user = unwrap await getUserByName(userName);
  delete user.password;
  return some(user);
  // Where `some` is
  // const some = <T>(value: T): Some<T> => new Some(value);
}

So, the Symbol.result is the same as then for Promises + await. It is a way to write monadic code in the imperative way.

Now, we are using the Symbol.iterator and yield* for the same reason:
https://effect.website/docs/getting-started/using-generators/

Actually, the Effect type also can implement the Resultable (or Unwrapable) interface.

@ljharb
Copy link

ljharb commented Nov 2, 2024

That seems like overkill to me - other types should just have a way to vend a Result.

@DScheglov
Copy link

@ljharb

That seems like overkill to me - other types should just have a way to vend a Result.

So perhaps all types should just vend an Array? Why do we need a Symbol.iterator?
Why does async/await support PromiseLike instead of just Promise?

We already have many existing solutions, especially in TypeScript, and we want to move away from generators, aiming for a simpler way to handle monadic types in imperative code.

The idea is that the try expression simply returns a Result.

@ljharb
Copy link

ljharb commented Nov 2, 2024

Because an iterator doesn’t need all the items up front, an array does - there’s a massive difference between iteration and a two-path container.

Protocols make things slower and more complex, and should only exist when it’s necessary. An operator for unwrap feels unnecessary too - just make it a Result method.

@DScheglov
Copy link

DScheglov commented Nov 2, 2024

@ljharb

An operator for unwrap feels unnecessary too - just make it a Result method.

Could you please clarify how it should work?

@ljharb
Copy link

ljharb commented Nov 2, 2024

I would assume that it would be something like try <expression>. This would return a Result instance. That Result would represent a success path vs a failure path (yes/no, or whatever terms worked best). It would have methods or accessors that revealed the internal state of the instance, including "if it failed, throw the failure reason as an exception, otherwise return the success value" (unwrap, i assume).

@DScheglov
Copy link

@ljharb

including "if it failed, throw the failure reason as an exception, otherwise return the success value" (unwrap, i assume).

Almost.

Rust has type Result<T, E> that has method unwrap that works as you pointed.
In the same time Rust has a postfix operator ?, that unwrap the result or returns the Err from the function where it has been called:

use std::num::ParseIntError;

fn multiply(first_number_str: &str, second_number_str: &str) -> Result<i32, ParseIntError> {
    let first_number = first_number_str.parse::<i32>()?; // <<<<< ? is applied here
    let second_number = second_number_str.parse::<i32>()?; // <<<<< ? is applied here

    Ok(first_number * second_number)
}

fn print(result: Result<i32, ParseIntError>) {
    match result {
        Ok(n)  => println!("n is {}", n),
        Err(e) => println!("Error: {}", e),
    }
}

fn main() {
    print(multiply("10", "2"));
    print(multiply("t", "2"));
}

In TS and correspondently in JS we need to be able to write the same;

class ParseIntError extends Error {};

function parseInteger(input: string): Result<number, ParseIntError> {
  const int = parseInt(input, 10);
  if (isNaN(int)) return err(new ParseIntError(`${input} is not an int`));
  return ok(int);
}

function mult(inputA: string, inputB: string): Result<number, ParseIntError> {
  const a = unwrap parseInteger(inputA);
  const b = unwrap parseInteger(inputB);

  return ok(a * b);
}

function print(result: Result<number, ParseIntError>) {
  result.match({
    ok: (value) => console.log(`n is ${value}`),
    err: ({ message }) => console.error(`Error: ${message}`)
  });
}

function main() {
  print(mult("10", "2"));
  print(mult("t", "2"));
}

see in Playground

Now we have to write the same code using generators:

import { type Result, ok, err, Do } from 'resultage';

class ParseIntError extends Error {};

function parseInteger(input: string): Result<number, ParseIntError> {
  const int = parseInt(input, 10);
  if (isNaN(int)) return err(new ParseIntError(`${input} is not an int`));
  return ok(int);
}

const mult = (inputA: string, inputB: string): Result<number, ParseIntError> =>
  Do(function*() {
    const a = yield* parseInteger(inputA);
    const b = yield* parseInteger(inputB);

    return ok(a * b);
  });

function print(result: Result<number, ParseIntError>) {
  result.match(
    (value) => console.log(`n is ${value}`),
    ({ message }) => console.error(`Error: ${message}`),
  );
}

function main() {
  print(mult("10", "2"));
  print(mult("t", "2"));
}

see in TS Playground or in CodeSandbox

The unwrap operator couldn't be replaced with instance methods, because it doesn't throw, it forces the return from the current function:

function f() {
  const value = unwrap result;
  return value;
}

is the same for:

function f() {
  if (result.isErr) return result;
  const value = result.unwrap();
  return value;
 }

@ljharb
Copy link

ljharb commented Nov 2, 2024

ok, well, adding a new keyword that’s another kind of return isn’t likely to be acceptable either, so that doesn’t sound like a viable path.

@DScheglov
Copy link

ok, well, adding a new keyword that’s another kind of return isn’t likely to be acceptable either, so that doesn’t sound like a viable path.

But this isn't new. We already have yield*, which also acts as a kind of return. Other languages have similar operators.

@ljharb
Copy link

ljharb commented Nov 3, 2024

I suspect you'll find that generator syntax isn't universally loved.

@DScheglov
Copy link

@ljharb

I suspect you'll find that generator syntax isn't universally loved.

Of course not. First of all, it's an inappropriate use of generators.
Also, generators aren't particularly popular in JS and TS on their own.
That's why we need the "unwrap" operator with Result type.

@LuKks
Copy link

LuKks commented Nov 6, 2024

Simple userland module also

https://github.com/LuKks/like-safe

const safe = require('like-safe')

// Sync
const [res1, err1] = safe(sum)(2, 2) // => [4, null]
const [res2, err2] = safe(sum)(2, 'two') // => [null, Error]

// Async
const [res3, err3] = await safe(sumAsync)(2, 2) // => [4, null]
const [res4, err4] = await safe(sumAsync)(2, 'two') // => [null, Error]

// Shortcut for Promises
const [res5, err5] = await safe(sumAsync(2, 2)) // => [4, null]
const [res6, err6] = await safe(sumAsync(2, 'two')) // => [null, Error]

@AlexanderFarkas
Copy link

Why create a very special and opinionated syntax for a structure which can be replaced by a single function wrapper? Moreover, handling error/data is proved to be error-prone in languages adapted this pattern of error handling (like Go).

@DScheglov
Copy link

@AlexanderFarkas

Moreover, handling error/data is proved to be error-prone in languages adapted this pattern of error handling (like Go)

Do you have any references for that? I'd be really interested to take a look!

@AlexanderFarkas
Copy link

@DScheglov
Russ Cox on error handling in Go
Related thread on Reddit

I understand that JS might not have these issues due to having the way to opt out of this Safe Assignment and use plain old try-catch. But here comes the question - if you want to opt out of this feature in complex cases, why integrate it as a language feature, considering its behavior can be replaced with a single function call.

@DScheglov
Copy link

DScheglov commented Nov 17, 2024

@AlexanderFarkas

I'm not sure that I got your point correctly.

Russ Cox on error handling in Go

This video is about fixing the issue of manual propagation by introducing the check operator in Go.
Yes, it is annoying to propagate errors manually, but the new syntax doesn't break the automatic propagation of exceptions. It just introduces another way to catch them. And yes, this new way leads to ignoring errors more quiet than with a try catch.

Related thread on Reddit

Unfortunatelly the original artical (or post) couldn't be reached. Perhaps you have another link on that?
(and actually it seems like it is relative old ~6 years ago).

@fabiancook
Copy link

Updated link: https://www.boramalper.org/blog/go-s-error-handling-sucks-a-quantitative-analysis/

In conclusion, 3.88% of the Go code written (excluding comments and blanks) consist of manual error propagation, which is problematic.

@DScheglov
Copy link

DScheglov commented Nov 17, 2024

@AlexanderFarkas , @fabiancook

Thank you for pointing out the error propagation issue.

Yes, Go's error handling is definitely not perfect. :)

But this proposal doesn't break the auto-propagation of exceptions in JS, fortunately - it is just a new way to catch and handle errors, nothing more.

Considering the goroutines, I guess Go's panic is not used for "exceptions" for the same reason why Node.js callback-API doesn't throw errors but instead invoke the callback with an error.

In JS we have exceptions and rejected promises that make our life much easier. Also we can use unions to model non-exceptional errors similar to Rust's Result or Haskell's Either.

So, despite that initial propose was inspired by Go's, it will not bring the problems of Go's error handling to JS/TS. It will bring other ones, but not Go's.

And finally regarding the

why integrate it as a language feature, considering its behavior can be replaced with a single function call

It is normal for JS to replace some easy-to-implement features with language or "standard library" functionality.

Actually in this specific case, it is not easy to implement, and moreover, it is difficult to make the solution intuitively clear for different developers—I ended up writing two pages of README for my own implementation to cover the edge-cases.

So, adding the try expression that returns a strictly-discriminating object/tuple is a good way to unify the JS/TS codebase and make it easier for new developers to get up to speed.

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