Proposing new syntax for zero-cost unwrapping options/results

TL;DR – Proposing let? as a new syntax with zero‑runtime. An alternative to nested switches or Option.flatMap chains. It unwraps Ok/Some values and early‑returns on Error/None, and works seamlessly with async/await.

Hi there!

ReScript encourages explicit error handling with result and missing‑value handling with option, but real‑world code can quickly end up in one of two painful places:

  1. “Indentation chaos” - switches nested in switches to unwrap options and results
  2. Long map / flatMap chains

While the second style isn’t wrong, each step allocates a real function call, which can hurt performance, and the chain soon becomes both verbose and harder to follow. They also do not play well with async/await, because a single async value forces the whole pipeline to become async.

Introducing let? for zero‑cost unwrapping

To fix this, we’ve been exploring adding a new syntax to ReScript. The goal is a simple, zero‑cost, ergonomic way to eliminate the indentation drift caused by nested switches and the extra function calls introduced by long map/flatMap chains. And, to treat async/await and other language features as first class:

We’re proposing a let? syntax for unwrapping options and results:

/* returns: string => promise<result<decodedUser, 'errors>> */
let getUser = async (id) => {
  /* if fetchUser returns Error e, the function returns Error e here. Same for all other `let?` */
  let? Ok(user) = await fetchUser(id)
  let? Ok(decodedUser) = decodeUser(user)
  Console.log(`Got user ${decodedUser.name}!`)
  let? Ok() = await ensureUserActive(decodedUser)
  Ok(decodedUser)
}

Each let? will unwrap the result (or option) it is passed, and automatically propagate the Error(e) / None if the underlying value is not Ok/Some.

If you were to write the code above today, it’d look like this:

// Before
let getUser = async id => {
  switch await fetchUser(id) {
  | Error(e) => Error(e)
  | Ok(user) =>
    switch decodeUser(user) {
    | Error(e) => Error(e)
    | Ok(decodedUser) =>
      Console.log(`Got user ${decodedUser.name}!`)
      switch await ensureUserActive(decodedUser) {
      | Error(e) => Error(e)
      | Ok() => Ok(decodedUser)
      }
    }
  }
}

The “before” code above is actually exactly what the let? code snippet will desugar to!

Some key points:

  • Only works for result and option now. Those are built in types that we want to promote usage of and where we see the most need for solving indentation chaos.
  • It works seamlessly with other language features such as async/await.
  • It’s intended to be visually clear what’s going on. We think it is thanks to both let? and that you have to explicitly say that you’re unwrapping a result (Ok(varName)) or option (Some(varName))
  • It’s a simple syntax transform - the let? becomes a switch. This means the generated code will be clear and performant with no extra closures/function calls. More on this later

Works for option as well

It also works with options:

type x = {name: string}
type y = {a: string, b: array<x>}
type z = {c: dict<y>}

// Before
let f = (m, id1, id2) =>
  m
  ->Dict.get(id1)
  ->Option.flatMap(({c}) => c->Dict.get(id2))
  ->Option.flatMap(({b}) => b[0])
  ->Option.map(({name}) => name)


// After
let f = (m, id1, id2) => {
  let? Some({c}) = m->Dict.get(id1)
  let? Some({b}) = c->Dict.get(id2)
  let? Some({name}) = b[0]
  Some(name)
}

Clear and performant generated code

One of the big benefits of this being a (specialized) syntax transform is that the generated JS is pretty much what you’d write by hand in JS - a series of if’s with early returns, no extra closures or function calls, and no indentation drift:

// This ReScript
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)
}

// Generates this JS
async function getUser(id) {
  let e = await fetchUser(id);
  if (e.TAG !== "Ok") {
    return {
      TAG: "Error",
      _0: e._0
    };
  }
  let e$1 = decodeUser(e._0);
  if (e$1.TAG !== "Ok") {
    return {
      TAG: "Error",
      _0: e$1._0
    };
  }
  let decodedUser = e$1._0;
  console.log("Got user " + decodedUser.name + "!");
  let e$2 = await ensureUserActive(decodedUser);
  if (e$2.TAG === "Ok") {
    return {
      TAG: "Ok",
      _0: decodedUser
    };
  } else {
    return {
      TAG: "Error",
      _0: e$2._0
    };
  }
}

Inspiration and considerations

This feature draws a lot of inspiration from Rust’s question mark operator (?) for unwrapping, as well as letops from OCaml. We don’t add new syntax lightly; any addition must reinforce the patterns we want to promote.

We feel this feature has landed in a good territory - allows us to promote the patterns we want you to use when writing ReScript (options, results, async/await, etc), while still staying lean with good and performant generated JS.

Right now let? only targets option and result; other custom variants still need a switch (by design).

Try it out and let us know what you think!

There’s an open PR for the feature, and you can try this out today by installing the PR artifacts:

npm i https://pkg.pr.new/rescript-lang/rescript@7582

Give it a spin, refactor a couple of functions, and tell us: does let? make your daily work clearer and faster? Any sharp corners we missed?

We’re looking forward to hearing what you think!

20 Likes

One of the nicer patterns that this enables is using result + polymorphic variants for errors (AKA “errors as values”), in a way that makes it super simple to accumulate any errors that can happen and then be forced to handle them explicitly:

type user = {
  id: string,
  name: string,
  token: string,
}

external fetchUser: string => promise<
  result<JSON.t, [> #NetworkError | #UserNotFound | #Unauthorized]>,
> = "fetchUser"

external decodeUser: JSON.t => result<user, [> #DecodeError]> = "decodeUser"

external ensureUserActive: user => promise<result<unit, [> #UserNotActive]>> =
  "ensureUserActive"

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

// ERROR!
// You forgot to handle a possible case here, for example: 
//  | Error(#Unauthorized | #UserNotFound | #DecodeError | #UserNotActive)
let main = async () => {
  switch await getUser("123") {
  | Ok(user) => Console.log(user)
  | Error(#NetworkError) => Console.error("Uh-oh, network error...")
  }
}

Notice how all errors that can possibly happen from each function called with let? is automatically accumulated and exhaustively checked for, with no extra effort on your part. If any of the called functions were to add another possible error, or you call a new function, those errors will automatically be accumulated for you as well.

This is a very ergonomic and efficient pattern for error handling, that also produces performant code.

9 Likes

@xfcw I remember you were looking for a solution for this, what do you think?

Every now and then someone asked for something like this and it was more or less rejected (e.g. thread). What is different or what has changed your minds?

Don’t get me wrong, I like the idea and the syntax. It’s a long awaited feature.

I’ve always thought some kind of ergonomic enhancement to map / flatmap is needed for results to make them more useful, and happy to see this proposal.

Are there rules to when they can be used? For example can you unwrap an option and result in the same function -

let foo = async () => {
  let? Some({c}) = m->Dict.get(id1)
  let? Ok(obj) = Decode.decode(c, decoder)
  Ok(obj)
}

No, since it’s a “continuation style” transform (it gets turned into a switch) that will not work. The first let? sets the type for the rest of that block.

One part is of course that - this surfaces as a wanted feature fairly often. Features along these lines have been up for debate from time to time, but this type of feature is difficult to find a good trade off for.

A lot of thought goes into trying to keep the language simple and coherent, with a rather small syntax and API surface. That keeps the language approachable. For this particular issue people are experiencing, we feel this type of feature is a good trade off that also enables (or at least vastly enhances) patterns we want to encourage.

We’ve done a lot of work enhancing some of the builtin types (dicts with dedicated syntax + pattern matching, results are now builtin properly to the language, options always was, etc), working through the editor tooling, and so on. Encouraging using those builtins are a priority for us as well, since it’s so much easier to work on making a few set concepts and types as well supported as possible, rather than trying to cater to the general case. This is about tooling and ergonomics, but also about compiler optimizations.

Finally, one good part about let? is that since it’s just a simple syntax transform, ejecting from it (if it should ever be needed) would be trivial.

4 Likes

Thanks for pinging me!

I absolutely love this idea, I need it now.

One thought that occurred: What if I want to unwrap and catch an error without bubbling it up?

Rust has a let Ok(foo) = bar else {…} where I can panic.

5 Likes

I’m usually a fan of map and flatMap when using Option, but removing the runtime hit for calling functions is very appealing.

Here’s I function I found in one of my projects.

let query =
  ctx.url.search
  ->String.replace("?", "")
  ->String.split("&")
  ->Array.map(String.split(_, "="))
  ->Array.find(x => x[0] == Some("query"))
  ->Option.flatMap(Array.at(_, 1))
  ->Option.map(decodeURIComponent)
  ->Option.getOr("")

Which I think would look like this:

let query = {
  let? Some(query) = ctx.url.search
    ->String.replace("?", "")
    ->String.split("&")
    ->Array.map(String.split(_, "="))
    ->Array.find(x => x[0] == Some("query"))

  let? Some(uriComponent) = query->Array.at(1)
  let? Some(decoded) = decodeUriComponent(uriComponent)

  decoded->Option.getOr("")
}

I’m not a fan of the extra names for things and I would probably just use Some(x) all the time. This would remove any ReScript specific functions from the output (excluding the getOr), which would be really nice.

Having a way to easily bail to a fallback value like Option.getOr would be nice to have. I would have to have that be a switch right now to get rid of that function.

I’m not sure anything I am doing get seriously impacted enough by the performance for me to move away from the pipe and pointfree style I like, but it is nice to have this available.

Not 100% sure I follow, but if you don’t want to bubble it you could just not use let? for that particular thing. You can still do whatever you want in between let?, just that the return type of the block will need to be whatever let? says, below it.

So, if you wanted to panic on an error it’d be as simple as using a Result.getOrThrow. Or a switch + panic(). Or any of the other existing Result helpers that might be fitting.

1 Like

I’m not a fan of let? syntax. Mixing text and symbols just looks complicated

I propose using maybe instead.

Then the examples would look like this

/* returns: string => promise<result<decodedUser, 'errors>> */
let getUser = async (id) => {
  /* if fetchUser returns Error e, the function returns Error e here. Same for all other `maybe` */
  maybe Ok(user) = await fetchUser(id)
  maybe Ok(decodedUser) = decodeUser(user)
  Console.log(`Got user ${decodedUser.name}!`)
  maybe Ok() = await ensureUserActive(decodedUser)
  Ok(decodedUser)
}

I like it because maybe is already a well known convention with optionals. e.g. type maybeUser = option<user>

This might be naive, I’m not much of a language architect. Let me know what you think

2 Likes

I really like this addition! Whatever you decide to name this is great

Just a couple of thoughts.
Using the ? I think makes it confusing from wherever you come.

  • In Javascript is already used for ternaries (also in RS), nullish coalescing and optional chaining (features that could be introduced to ReScript in the future)
  • In Rust the ? is an operator and not part of the let binding
  • In Ocaml we have let*

So why not use let* instead?

But I do think let? is much better than maybe, I’d much rather have a single operator to bind values, instead of 2. It’s easier to add a little decoration to let than to write another word.

Edit:
Something I forgot was that Javascript has yield* (with a *) which EffectTS uses quite a lot for a similar purpose, and someone that might be interested in ReScript is likely also familiar with Effect.

3 Likes

let? is the best option so far. Would it be possible to just have let or would that make parsing too complex? let* looks wrong in ReScript to me, and maybe feels waaay out there.

1 Like

Lots of good feedback and things to unwrap (no pun intended), thank you!

We’d like to avoid to add more keywords unless we really have to. let? is good like that because it doesn’t interfere with anything existing. Using maybe would mean that it’d become a keyword, which means you couldn’t use it anywhere else.

Another naming that came up was let.unwrap. It would make the example:

/* returns: string => promise<result<decodedUser, 'errors>> */
let getUser = async (id) => {
  /* if fetchUser returns Error e, the function returns Error e here. Same for all other `maybe` */
  let.unwrap Ok(user) = await fetchUser(id)
  let.unwrap Ok(decodedUser) = decodeUser(user)
  Console.log(`Got user ${decodedUser.name}!`)
  let.unwrap Ok() = await ensureUserActive(decodedUser)
  Ok(decodedUser)
}

But it’s also a lot more verbose than let?.

Funny, the feeling was the exact opposite when we came up with it - “this will be familiar to most people - ? for unwrapping in Rust, ? for optional chaining (a form of unwrapping) in JS” :smile: The real world is not always what one thinks.

let* would be confused with OCaml letops (this proposal is not the same as OCaml letops), so that would probably be quite difficult. Plus at least personally I think that let* isn’t intuitive and honestly quite confusing of a syntax.

Effect is cool and all, but the syntax to use it is alien to pretty much whoever looks at it. So while it would give familiarity to the small subset of people interested in both Effect and ReScript, it would be the wrong way of going about it honestly (taking a “bad” syntax just for familiarity).

One good way of thinking about it is that if the Effect people could choose freely for syntax, would they keep yield*? Pretty sure they would not.

In theory, yes, it would be possible to have just let. But it’d require trade offs.

This is an interesting point - there’s actually a bunch of things we’ve added here that’s “superflous” and not strictly needed, but we’ve added them anyway to make the code clearer (and the implementation simpler of course).

  • We have let? Ok(user) = ... but theoretically it could be just let? user = ... and we could look up the type of what holds user during typechecking, so you wouldn’t need to hint about it being a result via Ok. But, needing to wrap it in Ok or Some is good because it’s immediately clear what’s going on when you look at the code. This is really important. Together with marking the let binding with ?, it make it possible for the entire feature to be a ~60 LoC internal compiler transform. This is good.
  • Continuing on the above, we actually wouldn’t need the ? either… Most people probably don’t know, but let Ok(user) = ... is already perfectly valid syntax today. It’s just that it’ll yield a “partial pattern match is missing cases” warning (you can’t handle the Error case in a single let binding, and a let bindings left hand side is actually a pattern match). But, we could change that type of pattern match in let-binding to always trigger this feature we’re talking about, so that you wouldn’t need let? and always get that behavior. But, going by the same standards as detailed above, that would make the code unclear and magic at a glance - you wouldn’t know what type wrapped user, and via that you also wouldn’t know what type the entire function would have. Here, you can immediately see it’s either a result or an option by looking at what constructors is used for the unwrapping.

What point 2 above means is that we want something by the let-binding to signal that unwrapping will happen, since you won’t see that in the code by just looking at it. let? was the most liked and most clear alternative we could come up with.

Keep the feedback coming and the discussion going! It’s important we get these details right and exhaust all of our alternatives.

5 Likes

Glad I shared my thoughts. I 100% understand a new keyword is a major tradeoff. maybe reads so nice to me for some reason.

and in the same, but opposite vein let? really irks me. It stands out the same way the decimal operators like +. *. stood out to me. though hearing your reasoning does help, it doesn’t make me want to brag about it to JS/TS community.

Anyway
I think this feature is undeniably a banger. I would use it as is.

4 Likes

I think I would much prefer let? without needing to unwrap the Ok(_):

let getUser = async (id) => {
  /* if fetchUser returns Error e, the function returns Error e here. Same for all other `maybe` */
  let? user = await fetchUser(id)
  let? decodedUser = decodeUser(user)
  Console.log(`Got user ${decodedUser.name}!`)
  let? () = await ensureUserActive(decodedUser)
  Ok(decodedUser)
}

I don’t think I’ve ever needed to unwrap an option more than one level, and I usually do it inline, so in that sense I would only ever be using this as a let? <result> (and I would use this often) - so would prefer a simpler syntax

If possible, can the question mark go on the unwrapper? This I think conveys the meaning just as well:

let Ok?(user) = await fetchUser(id)
1 Like

We want to signify that it’s not a normal assignment. It’s an operation with two possibilities:

  1. Unwrapping on success or
  2. Return on error.

To me the following options read better:

Modifying the assignment operator

let Ok(user) ?= await fetchUser(id)

Modifying the right hand side expression of the assignment, which can either return a value or Error.

let Ok(user) = await fetchUser(id) ?

But that would mess with the ternary operator syntax.

Or like @hellos3b suggested, having the question mark on the unwrapper:

let Ok(user)? = await fetchUser(id)

The maybe keyword by @YKW is very appealing from a reader’s perspective as well.
I am not a fan of the let? as well - it lends a cryptic vibe to the language. We should strive to have an extremely readable syntax.

My 2 cents, this will be super useful in keeping code readable and flat, and I think it is a great addition.

let? is fine, easy to get used to it and straightforward once you know.

Like @hellos3b mentioned, if this could use the type info so that we wouldn’t need to add Ok or Some it would look even neater, but it is fine if not, still very useful.

Love the composable errors snippet too, even cooler with this sugar.

Can’t wait to use this!

4 Likes

I’m not sure about removing the Ok part. what if we want to support other stuff in the future? would let? Error(e) = fn() make sense? what about let? MyVariant1(x, y) = fn()? are there scenarios where this would make sense? maybe it would only make sense with else, like Rust does it: let? MyVariant1(x) = fn() else {panic(...)}

2 Likes

I really like the explicitness of the syntax and as said @xfcw it also allows a potential generalization in the future.

1 Like