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!

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

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

4 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