TL;DR – Proposing
let?
as a new syntax with zero‑runtime. An alternative to nestedswitch
es orOption.flatMap
chains. It unwrapsOk
/Some
values and early‑returns onError
/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:
- “Indentation chaos” - switches nested in switches to unwrap options and results
- 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
andoption
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!