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!

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

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

3 Likes