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,