let?
A new let-unwrap syntax just landed in ReScript. Experimental!

After long discussions we finally decided on an unwrap syntax for both the option
and result
types that we are happy with and that still matches the explicitness of ReScript we all like.
let?
or let-unwrap
is a tiny syntax that unwraps result
/option
values and early-returns on Error
/None
. It’s explicitly experimental and disabled by default behind a new "experimental features" gate. See below how to enable it.
Before showing off this new feauture, let's explore why it is useful. Consider a chain of async
functions that are dependent on the result of the previous one. The naive way to write this in modern ReScript with async
/await
is to just switch
on the results.
Note: While we are cautiously optimistic with this implementation of let?
, we still consider it experimental and thus hide it behind a compiler flag that the user explicitly needs to activate. It might change so use at your own risk.
RESlet getUser = async id =>
switch await fetchUser(id) {
| Error(error) => Error(error)
| Ok(res) =>
switch await decodeUser(res) {
| Error(error) => Error(error)
| Ok(decodedUser) =>
switch await ensureUserActive(decodedUser) {
| Error(error) => Error(error)
| Ok() => Ok(decodedUser)
}
}
}
Two observations:
with every
switch
expression, this function gets nested deeper.The
Error
branch of everyswitch
is just an identity mapper (neither wrapper nor contents change).
The only alternative in ReScript was always to use some specialized functions:
RESlet getUserPromises = id =>
fetchUser(id)
->Result.flatMapOkAsync(user => Promise.resolve(user->decodeUser))
->Result.flatMapOkAsync(decodedUser => ensureUserActive(decodedUser))
Note: Result.flatMapOkAsync
among some other async result helper functions are brand new in ReScript 12 as well!
This is arguably better, more concise, but also harder to understand because we have two wrapper types here, promise
and result
. And we have to wrap the non-async type in a Promise.resolve
in order to stay on the same type level. Also we are giving up on our precious async
/await
syntax here. Furthermore, those functions result in two more function calls.
JSfunction getUserPromises(id) {
return Stdlib_Result.flatMapOkAsync(
Stdlib_Result.flatMapOkAsync(fetchUser(id), (user) =>
Promise.resolve(decodeUser(user)),
),
ensureUserActive,
);
}
Let's have a look at the generated JS from the initial example in comparison:
JSasync function getUserNestedSwitches(id) {
let error = await fetchUser(id);
if (error.TAG !== "Ok") {
return {
TAG: "Error",
_0: error._0,
};
}
let error$1 = decodeUser(error._0);
if (error$1.TAG !== "Ok") {
return {
TAG: "Error",
_0: error$1._0,
};
}
let decodedUser = error$1._0;
let error$2 = await ensureUserActive(decodedUser);
if (error$2.TAG === "Ok") {
return {
TAG: "Ok",
_0: decodedUser,
};
} else {
return {
TAG: "Error",
_0: error$2._0,
};
}
}
Introducing let?
Let's rewrite the above example again with our new syntax:
This strikes a balance between conciseness and simplicity that we really think fits ReScript well.
With let?
, we can now safely focus on the the happy-path in the scope of the function. There is no nesting as the Error
is automatically mapped. But be assured the error case is also handled as the type checker will complain when you don't handle the Error
returned by the getUser
function.
This desugars to a sequence of early-returns that you’d otherwise write by hand, so there’s no extra runtime cost and it plays nicely with async/await
as the example above suggests. Check the JS Output
tab to see for yourself!
Of course, it also works for option
with Some(...)
.
RESCRIPTlet getActiveUser = user => {
let? Some(name) = activeUsers->Array.get(user)
Some({name, active: true})
}
It also works with the unhappy path, with Error(...)
or None
as the main type and Ok(...)
or Some(...)
as the implicitly mapped types.
RESCRIPTlet getNoUser = user => {
let? None = activeUsers->Array.get(user)
Some("No user for you!")
}
let decodeUserWithHumanReadableError = user => {
let? Error(_e) = decodeUser(user)
Error("This could not be decoded!")
}
Beware it targets built-ins only, namely result
and option
. Custom variants still need switch
. And it is for block or local bindings only; top-level usage is rejected.
RESCRIPTlet? Ok(user) = await fetchUser("1")
// ^^^^^^^ ERROR: `let?` is not allowed for top-level bindings.
Note: result
and option
types cannot be mixed in a let?
function!
A full example with error handling
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...")
}
}
Experimental features
We have added an experimental-features infrastructure to the toolchain. If you use the new build system that comes with ReScript 12 by default, you can enable it in rescript.json
like so:
JSON{
"experimental-features": {
"letUnwrap": true
}
}
If you still use the legacy build system, enable it with the compiler flag -enable-experimental
:
JSON{
"compiler-flags": ["-enable-experimental", "LetUnwrap"]
}
We would love to hear your thoughts about this feature in the forum. Please try it out and tell us what you think!
Happy hacking!