Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions _blogposts/2025-09-01-let-unwrap.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
---
author: rescript-team
date: "2025-09-01"
badge: roadmap
title: let?
description: |
A new let-unwrap syntax just landed in ReScript.
---

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.

# What is it exactly?

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

### Example

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.

```res
let 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:
1. with every `switch` expression, this function gets nested deeper.
2. The `Error` branch of every `switch` is just an identity mapper (neither wrapper nor contents change)

This means even though `async`/`await` syntax is available in ReScript for some time now, it is also understandable that people created their own `ResultPromise` libraries to handle such things with less lines of code, e.g.:

```res
module ResultPromise = {
let flatMapOk = async (p: promise<'res>, f) =>
switch await p {
| Ok(x) => await f(x)
| Error(_) as res => res
}
}

let getUserPromises = id =>
fetchUser(id)
->ResultPromise.flatMapOk(user => Promise.resolve(user->decodeUser))
->ResultPromise.flatMapOk(decodedUser => ensureUserActive(decodedUser))
```

While this is much shorter, it is 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.

```rescript
let getUser = async (id) => {
let? Ok(user) = await fetchUser(id)
let? Ok(decodedUser) = decodeUser(user)
let? Ok() = await ensureUserActive(decodedUser)
Ok(decodedUser)
}
```
With the new `let-unwrap` syntax, `let?` in short, we now have to follow the happy-path (in the scope of the function). And it's immediately clear that `fetchUser` is an `async` function while `decodeUser` is not. 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.

<!-- TODO: demonstrate error handling with polymorphic variants a little more -->

This desugars to a **sequence** of `switch`/early-returns that you’d otherwise write by hand, so there’s **no extra runtime cost** and it plays nicely with `async/await`. Same idea works for `option` with `Some(...)` (and the PR also extends support so the left pattern can be `Error(...)`/`None`, not just `Ok(...)`/`Some(...)`).

Beware it targets built-ins only: `result` and `option`. (Custom variants still need `switch`.) And it is for block or local bindings only; top-level usage is rejected.
Compiled JS code is the straightforward if/return form (i.e., “zero cost”).

### How to enable it (experimental)

We have added an **experimental-features infrastructure** to the toolchain. The corresponding compiler flag is `-enable-experimental`. This means you can enable `let?` in your `rescript.json`s `compiler-flags` and it forwards the feature to the compiler.

This is purely a syntactical change so performance is not affected.


Loading