Proposing new syntax for zero-cost unwrapping options/results

I don’t see how removing the explicit variant would impede use of this syntax with “custom” types in the future.

Instead of having to specify which variant we unwrap in the let? you could have an annotation in the type saying which one unwraps with let?.

I think the main question is: Does it make sense to use let? with Error or with None?

If it makes sense, then having to specify the variant in let? Error(err) would make sense because some times you may want to unwrap one or the other variant and return the other one.

If it doesn’t make sense, having to specify Ok or Some all the time is just noise.

Just for comparison, here is what the ? operator would look like in rust, with the example in the first post:

async fn get_user(id: u32) -> Result<User, UserError> {
    let user = fetch_user(id).await?;
    let decoded_user = decode_user(user).await?;
    println!("Got user {}!", decoded_user.name);
    ensure_user_active(&decoded_user).await?;
    Ok(decoded_user)
}

Compare to

let getUser = async (id) => {
  let? Ok(user) = await fetchUser(id)
  let? Ok(decodedUser) = decodeUser(user)
  Console.log(`Got user ${decodedUser.name}!`)
  let? Ok() = await ensureUserActive(decodedUser)
  Ok(decodedUser)
}

It is very very close, except for the Ok redundancy.

So I think once the question is clearly answered, the design falls off the decision: Does it make sense to use let? with Error or with None?



From my personal opinion, let? should work always the same with a certain type, and for result it is by unwrapping Ok and returning Error, same for option. Given this, removing the variant in the left side of the assignment would make code nicer.

In any case, as proposed, I still think this would be great, and this is just splitting hairs. Full support for this.

1 Like

As we spoke a lot about this on the retreat and after on discord, I really love this proposal, and the proposed syntax as in this post. I have a lot of use cases with server components and API code where this can clean up a lot of this code. Can’t wait to use it!

2 Likes

If we are open to a keyword, then I propose the peculiar keyword unlet instead of maybe:

unlet Ok(user) = await fetchUser(id)

It would mean, unless the RHS expression returns/resolves to an Error, we can expect the value to be unwrapped and let-assigned to user variable. That also makes let and unlet keywords siblings. I hardly think anyone would use the unlet keyword for something else.

I agree with keeping OK() for the sake of clarity and keeping it unmagical.

1 Like

Without meaning to sound harsh or rude, unlet is possibly the worst keyword I’ve ever seen, unless its intention is to unbind a let-binding or something similar… confusing to no end. sorry dude, unlet is not the right keyword here

1 Like

What about

let getUser = async (id) => {
  lets Ok(user) = await fetchUser(id)
  lets Ok(decodedUser) = decodeUser(user)
  Console.log(`Got user ${decodedUser.name}!`)
  lets Ok() = await ensureUserActive(decodedUser)
  Ok(decodedUser)
}
  • No symbols
  • Is a proper word
  • “s” is reminiscent of “throwS”, but emphasizing the prevailing importance of the passing part rather than the throwing one, kind of a counterpart of “throwS”, as in “lets the value pass” rather than emphasizing “throws an error”
  • Or maybe reminiscent of “[S]witch” that this hides

Or

let getUser = async (id) => {
  let/ Ok(user) = await fetchUser(id)
  let/ Ok(decodedUser) = decodeUser(user)
  Console.log(`Got user ${decodedUser.name}!`)
  let/ Ok() = await ensureUserActive(decodedUser)
  Ok(decodedUser)
}
  • Is very clear – the dash is a much cleaner symbol than “?” which is very muddy, one of the muddiest tbh
  • Let’s you focus on the meaningful text to the right of it easier while reminding us that the meta is let
  • The dash is very visible and lets you see all such lets in the code immediately
  • Is on the same key as “?” on many keyboards, but doesn’t require you pressing Shift all the time

Or

let getUser = async (id) => {
  let| Ok(user) = await fetchUser(id)
  let| Ok(decodedUser) = decodeUser(user)
  Console.log(`Got user ${decodedUser.name}!`)
  let| Ok() = await ensureUserActive(decodedUser)
  Ok(decodedUser)
}
  • Same pros as “/”, but reminds of how “|” is used in such languages, and, again, of the switch syntax that it’s “hiding”
  • But it needs you holding Shift…
1 Like

I have doubts…

On the first hand, I would definitely go without the ?. I agree with some other posts that the question mark removes readability.

My argument is that question marks in all the spoken languages that I know of are reserved for the start or end of a phrase. Even the ternary operator can be understood as “question ? answer : answer”.

With the let? construct, it is not at all clear what the question is.

let Ok(user) = await ....
let Ok(banana) = user.find("banana")

Cost

My main worry is more about the real usefulness of this pattern with regards to the cost it adds:

  • Reduced readability (alternative part of the type matching is hidden)
  • Forces the use of (Ok/Error) (or some common type) instead of better variants
  • Pushes error handling out where a continuation/retry and other patterns would make more sense
  • Adds a new decision while writing code “Should I use the unwrap ?” or when defining types “Should I define my types as Ok/Error to support unwrap ?”

This is a very high cost to reduce code indentation.

What is wrong with this code ?

let veryComplicatedService = (id) => {
  let user = switch fetchUser(id) {
    | Ok(user) => user
    | err => throw MySystemException("some context")
  }
  // Code never reached on error, no deep nesting.

  // Can have an explicit try/catch to return "Error"
}

I would reserve this kind of magic for a language extension, not as a “good practice”.

1 Like

Not using a question mark would remove a valid syntax I use in tests where I’m sure of the shape I expect.

The let? syntax is not perfect, but I still prefer it over the other syntaxes that were suggested.

Regarding the benefit of it, I agree with you, we have to be careful about its usage and keep it in limited locations where you combine multiple complex data flows. In OCaml, the pervasive use of let* in some code bases definitely hurt readability and ease of maintenance by focusing way too much on the happy path.

1 Like

I very much like the originally proposed version.

Comparing

let? Ok(user) = await fetchUser(id)
let? Ok(decodedUser) = decodeUser(user)
let? Ok() = await ensureUserActive(decodedUser)
let someThingElse = ...

to something like

let Ok(user) ?= await fetchUser(id)
let Ok(decodedUser) ?= decodeUser(user)
let Ok() ?= await ensureUserActive(decodedUser)
let someThingElse = ...

I find that

  1. In the first version, when reading the code, I can see at the very first glance which lets are “normal” lets and which are not, without having to scan further to the right.
  2. In the first version, the ? are neatly stacked below each other. (And I think it will be a very frequent use case to have multiple lines with let? “stacked” like in the example.)

I also think that the ? is a good choice compared to other characters as it is familiar for dealing with options (optional chaining) in TypeScript.

2 Likes

So, just to clarify again - adding a new keyword to the language has an extremely high bar because of the costs involved. We’ve been working hard to get rid of keywords rather than the other way around. So unless we come up with something extraordinary, we will not be adding a new keyword for this outside of a modification of let like the proprosed let?.

Some more comments:

The modification (? in let?) needs to happen in the let binding itself. This means in the let keyword, or possibly in the assignment (?=). But all other places are out as of now, because they would either interfere with existing features or complicate exploring other features in the future (like optional chaining).

The current design with explicit unwrapping means that you could, in theory in the future, get to decide yourself which constructor you want to unwrap. If you have a variant with more than 1 possible “continuation” constructor.

This can be a problem of course, but the hidden part never has any logic, it just forwards None or Error(e). So our belief is that it’ll be fast to pick up and understand. But it’s less obvious than stating the full code of course, which is a trade off for sure.

This is a design goal of this feature though. In order to encourage composability and interop between libs and so on, some form of common type is needed. Result and options are perfect for that, and they’re first class in the language for this very reason.

But, the design of this feature leaves room for exploring using this with other variants as well in the future, especially thanks to being explicit about which case you’re unwrapping.

Nothing is wrong with this code, you can work like that today if you want to. But the pattern we’re talking about here is for propagating errors-as-values (or None), which is another thing than using exceptions.


Two things are important to highlight here, seeing what questions are asked in general in this thread:

  • We’re not hiding any happy path here. None or Error(e) is propagated, so you’ll be forced to explicitly handle them somewhere. This is important and part of the design.
  • If we for some reason were to find that this feature is a net negative, rolling it back it would be as simple as just transforming to switches. So the cost of experimenting with this is low, and essentially risk free.

Again, thanks for all your thoughts and feedback! We read and consider all of it, and it’s very valuable.

Ultimately not everyone will be 100% happy with whatever we chose to move forward with. Such is the life of a programming language :smile: But hearing your thoughts, feedback and ideas is extremely valuable and important to us.

Keep it coming. Right now we’re aiming to move forward with this for v12.1 the soonest.

10 Likes

Gleam neither has the ternary operator nor the if-else construct. It instead uses the equivalent construct for switch called case. It uses case as the do-it-all for control flow. It also has use keyword for making it easy to work with callbacks. The combination of use and result.try has interesting parallels compared to what’s being done here with await or anything that yields a Result and its unwrapping .

In the end, this feature will deliver improvements which will make writing ReScript a better experience if not ideal. So I’m happy with this step forward. Looking forward to a swift v12.1 release.

Also, thank you for genuinely considering my wacky ideas!

4 Likes

Yeah, and we’re somewhat close in the sense that we also primarily recommend switches and pattern matching. But, one of ReScript’s goals is to be familiar to JS devs, and if’s and ternaries are widely used there. Both have their place in ReScript, but there’s no denying that the switch is one of the most powerful things we have.

Yup, quite close! It’s similar to OCaml’s letop. But, both of those rely on callbacks. let? uses no callbacks, it all gets compiled to nice, optimization friendly JS with very little “fat”, as shown in the original post. That’s important to us.

Of course, thanks for posting them! And keep them coming. As we’ve said before, it’s important to exhaust all ideas to make sure we make a well thought through trade off in what path we choose.

2 Likes

Maybe I’m a little bit late, but this | Error(_) as e => e will have better output.

2 Likes

I didn’t know about this so thanks (sorry for OT though). I feel like it should be optimized to the same output though

I think a feature around this is essential – there’s no elegant or easy around it. This is because the option type is monadic. You don’t avoid the complexity that introduces by trying to ignore the monadic style that it requires. I’m not saying the devs are trying to ignore that but some kind of special handling is necessary to make this work well. I think the let-punning proposal above with let? is a great idea.

Lack of some kind of let-op or idiomatic bind for dealing with options has been my biggest pain point using ReScript

4 Likes

I don’t think it’ll be good without more explicitness. it’s not clear that let Ok(x) = fn() is going to return on Error. why not just generalize the already pattern-matched left-side to allow for an else statement that will complement the pattern match?

let Ok(x) = fn() else { ... }? and then also add let? for automatic returns. maybe if we supported both, it would make more sense to allow let? x = fn(), that is, without pattern match? and allow return keyword inside else:

let Ok(x) = fn() else { return Error("fail") } (ensure that else has either return or panic, like rust?)
and also
let? x = fn() which would automatically early-return the error (or None)