diff --git a/_blogposts/2025-09-01-let-unwrap.mdx b/_blogposts/2025-09-01-let-unwrap.mdx
new file mode 100644
index 000000000..9c689b28b
--- /dev/null
+++ b/_blogposts/2025-09-01-let-unwrap.mdx
@@ -0,0 +1,205 @@
+---
+author: rescript-team
+date: "2025-10-14"
+previewImg: /static/blog/rescript-12-let-unwrap.jpg
+badge: roadmap
+title: let?
+description: |
+ 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.
+
+```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).
+
+The only alternative in ReScript was always to use some specialized methods:
+
+```res
+let 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.
+
+## Introducing `let?`
+
+Let's rewrite the above example again with our new syntax:
+
+```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)
+}
+```
+
+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.
+
+Of course, it also works for `option` with `Some(...)`.
+
+```rescript
+let 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.
+
+```rescript
+let 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.
+
+```rescript
+let? Ok(user) = await fetchUser("1")
+// ^^^^^^^ ERROR: `let?` is not allowed for top-level bindings.
+```
+
+### A full example with error handling
+
+
+
+```rescript
+type user = {
+ id: string,
+ name: string,
+ token: string,
+}
+
+external fetchUser: string => promise<
+ result #NetworkError | #UserNotFound | #Unauthorized]>,
+> = "fetchUser"
+
+external decodeUser: JSON.t => result #DecodeError]> = "decodeUser"
+
+external ensureUserActive: user => promise #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...")
+ }
+}
+```
+
+```js
+async function getUser(id) {
+ let e = await fetchUser(id);
+ if (e.TAG !== "Ok") {
+ return e;
+ }
+ let e$1 = decodeUser(e._0);
+ if (e$1.TAG !== "Ok") {
+ return e$1;
+ }
+ 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 e$2;
+ }
+}
+
+async function main() {
+ let user = await getUser("123");
+ if (user.TAG === "Ok") {
+ console.log(user._0);
+ return;
+ }
+ if (user._0 === "NetworkError") {
+ console.error("Uh-oh, network error...");
+ return;
+ }
+ throw {
+ RE_EXN_ID: "Match_failure",
+ _1: ["playground.res", 28, 2],
+ Error: new 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](https://forum.rescript-lang.org/). Please try it out and tell us what you think!
+
+Happy hacking!
diff --git a/public/static/blog/rescript-12-let-unwrap.jpg b/public/static/blog/rescript-12-let-unwrap.jpg
new file mode 100644
index 000000000..537924d14
Binary files /dev/null and b/public/static/blog/rescript-12-let-unwrap.jpg differ
diff --git a/src/components/CodeExample.res b/src/components/CodeExample.res
index 8ea95f911..8d3b38621 100644
--- a/src/components/CodeExample.res
+++ b/src/components/CodeExample.res
@@ -157,6 +157,7 @@ module Toggle = {
label: option,
lang: option,
code: string,
+ experiments: option,
}
@react.component
@@ -243,7 +244,12 @@ module Toggle = {
let playgroundLinkButton =
tab->isReScript
? experiments
+ | None => ""
+ }}`}
target="_blank">
// ICON Link to PLAYGROUND
,
lang: option,
code: string,
+ experiments: option,
}
@react.component
diff --git a/src/components/Markdown.res b/src/components/Markdown.res
index 4b4a03cdc..c0e0291bf 100644
--- a/src/components/Markdown.res
+++ b/src/components/Markdown.res
@@ -287,7 +287,11 @@ module CodeTab = {
return element.props.metastring;
}")
@react.component
- let make = (~children: Mdx.MdxChildren.t, ~labels: array=[]) => {
+ let make = (
+ ~children: Mdx.MdxChildren.t,
+ ~labels: array=[],
+ ~experiments: option=?,
+ ) => {
let mdxElements = switch Mdx.MdxChildren.classify(children) {
| Array(mdxElements) => mdxElements
| Element(el) => [el]
@@ -315,6 +319,7 @@ module CodeTab = {
code,
label,
highlightedLines: Some(Code.parseNumericRangeMeta(metastring)),
+ experiments,
}
Array.push(acc, tab)->ignore
diff --git a/src/components/Markdown.resi b/src/components/Markdown.resi
index 9abe49e38..23d4ab0e9 100644
--- a/src/components/Markdown.resi
+++ b/src/components/Markdown.resi
@@ -99,7 +99,11 @@ module Code: {
module CodeTab: {
@react.component
- let make: (~children: Mdx.MdxChildren.t, ~labels: array=?) => React.element
+ let make: (
+ ~children: Mdx.MdxChildren.t,
+ ~labels: array=?,
+ ~experiments: string=?,
+ ) => React.element
}
module Blockquote: {
diff --git a/src/components/MarkdownComponents.res b/src/components/MarkdownComponents.res
index 784bc756f..cf69e4144 100644
--- a/src/components/MarkdownComponents.res
+++ b/src/components/MarkdownComponents.res
@@ -20,7 +20,7 @@ type t = {
@as("UrlBox")
urlBox?: React.componentLike, React.element>,
@as("CodeTab")
- codeTab?: CodeTab.props> => React.element,
+ codeTab?: CodeTab.props, string> => React.element,
/* Common markdown elements */
p?: P.props => React.element,
li?: Li.props => React.element,