RFC: Early Returns in ReScript

Proposal: Early Returns in ReScript

Framing

I’m putting this forward as a discussion starter, not as advocacy. I’m personally skeptical: new syntax is expensive in ways that aren’t always visible at the moment of addition - every feature is something readers must learn, tooling must support, and future features must coexist with. Simple, predictable constructs are one of ReScript’s real strengths, and the burden of proof for new syntax should be high.

So the question is not “would early returns sometimes be nice?”. It is whether the cases where they’re nice are concentrated enough, and structurally awkward enough today, to clear that bar. What follows is the strongest case I can make. I’d rather find out it doesn’t survive scrutiny than add syntax that didn’t need to be added.

What would help most is your own code. Drop in a snippet of how something looks today and how (or whether) early returns would change it - both “this would clearly be better” and “I tried it and it’s actually worse” are equally useful.

Summary

Early returns let a function exit as soon as a decisive condition is known. The strongest case for them in ReScript is structural, not cosmetic: some functions have linear, staged control flow, but today’s cleanest expression is often nested switches or helpers that exist mainly to recover a flat shape.

The Problem

Without early returns, functions that reject inputs, short-circuit on error, or skip work tend toward one of:

  1. deeply nested if/switch blocks,
  2. a large outer switch whose only job is to hold the main logic hostage until edge cases are handled.

These shapes are not always wrong, but they become expensive in functions mixing validation, parsing, fallbacks, and effects. The issue is not line count - it is that the available shapes do not always match the underlying logic.

What Current ReScript Already Does Well

When a function is case analysis over one input, a single exhaustive switch with guards is already excellent:

let publish = data =>
  switch data {
  | {slug: Some(slug)} if !validateSlug(slug) => Error("Invalid slug")
  | {id: Some(id), slug: Some(slug)} => savePublication(~id, ~slug)
  | {id: None} => Error("Missing id")
  | {slug: None} => Error("Missing slug")
  }

Experimental let ?value = myResult further solves uniform unwrap-or-propagate pipelines:

let runImport = (~readFile, ~parse, path) => {
  let ?contents = readFile(path)
  let ?payload = parse(contents)
  persistImport(payload)
}

Any proposal for early returns should concede both: if a function is one switch over one value, or a uniform propagation pipeline, that is already the right solution.

Where the Real Gap Is

The gap appears when the function is neither a single match nor a uniform pipeline - it is a staged workflow where each successful step produces a value needed later, but the exit conditions differ.

Without early returns:

let runImport = (~readFile, ~parse, ~lookupUser, path) =>
  switch readFile(path) {
  | Error(_) => Error("Could not read file")
  | Ok(contents) =>
    switch parse(contents) {
    | Error(message) => Error(message)
    | Ok(payload) =>
      switch lookupUser(payload.userId) {
      | None => Error("Unknown user")
      | Some(user) =>
        if user.isFree && payload.size > freeLimit {
          Error("payload exceeds free tier limit")
        } else {
          switch authorizeImport(user, payload.accountId) {
          | Denied(reason) => Error(reason)
          | Allowed(token) =>
            Audit.log("import-start", user.id)
            persistImport(~user, ~token, ~payload)
          }
        }
      }
    }
  }

With early returns:

let runImport = (~readFile, ~parse, ~lookupUser, path) => {
  let contents = switch readFile(path) {
  | Error(_) => return Error("Could not read file")
  | Ok(contents) => contents
  }

  let payload = switch parse(contents) {
  | Error(message) => return Error(message)
  | Ok(payload) => payload
  }

  let user = switch lookupUser(payload.userId) {
  | None => return Error("Unknown user")
  | Some(user) => user
  }

  if user.isFree && payload.size > freeLimit {
    return Error("payload exceeds free tier limit")
  }

  let token = switch authorizeImport(user, payload.accountId) {
  | Denied(reason) => return Error(reason)
  | Allowed(token) => token
  }

  Audit.log("import-start", user.id)
  persistImport(~user, ~token, ~payload)
}

The control flow is conceptually linear; the syntax becomes increasingly nested. Each successful extraction traps the rest of the workflow inside one more branch. Early returns let each stage discharge its failure case locally and leave the successful binding for the rest of the function - without helpers introduced purely for scope management.

let ? does not cover this case. It works when every step propagates the same carrier. It does not handle:

  • intermediate values that are custom variants rather than one built-in shape,
  • different exit values across steps,
  • mixing propagated failures with boolean or guard-based exits,
  • early success as well as early failure,
  • iteration that should stop on the first satisfactory element.

A custom variant illustrates the boundary:

type authorization<'a> =
  | Allowed('a)
  | Denied(string)
  | NeedsMfa

let completeLogin = (~lookupUser, ~authorize, id) =>
  switch lookupUser(id) {
  | None => Error("Unknown user")
  | Some(user) =>
    switch authorize(user) {
    | Allowed(session) => Ok(session)
    | Denied(reason) => Error(reason)
    | NeedsMfa => Error("MFA required")
    }
  }

This may still be a good switch, but let ? cannot generalize across arbitrary custom variants and exit shapes.

Async Amplifies This

Async/await is itself a control-flow-flattening feature - its purpose is to let you write asynchronous code as linear statements instead of nested callbacks or .then chains. Without return, you adopt that feature and then can’t fully realize it whenever stages fail asymmetrically: every non-uniform exit forces the rest of the function back into a nested branch.

The usual escape hatches are weaker in async. Extracting a helper means making it async, allocating a Promise, and adding an await at the call site. -> can’t chain across awaits, so pipeline composition is unavailable. Promise.then chains relocate nesting into callback scopes rather than removing it. And let ? handles uniform pipelines, but async code is precisely where carriers most often aren’t uniform - real async functions talk to multiple services with different failure shapes (option, result, booleans, HTTP statuses), and that’s exactly where let ? doesn’t reach.

When Early Returns Are the Better Choice

Early returns are justified when the alternative is structurally worse, not merely slightly less pretty - specifically when:

  • a function has one dominant happy path plus several staged failure exits,
  • each successful stage introduces a value needed later,
  • exits are not all expressible as one uniform propagation form,
  • custom variants or mixed carriers are part of the workflow,
  • avoiding returns would force extra nesting or scope-only helpers.

This matters most in effectful or staged code that sequences work, rather than describing the shape of one value.

When They Are Not

A small flat switch, a function whose branches are symmetric with no dominant path, or one that reads naturally as a single expression - none of these need early returns. “This switch is mildly verbose” is not a sufficient argument; ReScript’s expression-oriented tools are already strong, and a feature should not be added to shave lines off code that is already clear.

Conclusion

ReScript’s expression-oriented style is a strength, but becomes awkward when a function has asymmetric staged control flow. Early returns close exactly that gap - justified as a readability feature for guard-style exits before a dominant main path, not as a general invitation to imperative style. The mental model stays simple: pattern matching for variants, expressions when the function naturally is one, early returns for rejection gates.

Call for Examples

So - over to you. Post the snippet you reached for while reading this. The case for and against gets a lot sharper once it’s about specific code rather than hypotheticals.

7 Likes

I strongly disagree with early returns as they sweep under the rug logic that ReScript makes by design explicit.

The awkwardness is real but is evidence that you are not yet using the correct functional tools not that ReScript needs imperative escape hatches.

Going back to your main example, you can make the same logic “linear” instead of nested by using flatMaps:

let runImport = (~readFile, ~parse, ~lookupUser, path) => {
  readFile
  ->Result.mapError(_ => Error("Could not read file"))
  ->Result.flatMap(parse)
  ->Result.flatMap(payload =>
    switch lookupUser(payload.userId) {
    | None => Error("Unknown user")
    | Some(user) => Ok((user, payload))
    }
  )
  ->Result.flatMap(isUserOnLimit)
  ->Result.flatMap(((user, payload)) => {
    switch authorizeImport(user, payload.accountId) {
    | Denied(reason) => Error(reason)
    | Allowed(token) =>
      Audit.log("import-start", user.id)
      persistImport(~user, ~token, ~payload) // Assuming this is also a Result
    }
  })
}

This is not the same nesting as the original. This structure is a pipeline where each step either short-circuits or continues. That is what flatMap is by definition.

The proposal conflates two different problems: conceptual nesting (the logic has stages) and syntactic nesting (the code grows rightward). flatMap eliminates syntactic nesting while preserving, in fact, making explicit, the conceptual structure. Early returns eliminate the syntactic nesting by hiding the conceptual structure inside control flow, precisely one of the things that functional style tries to solve.

Finally the async argument actually proves the opposite. Promises where designed to be used on pipeline flows and async/await doesn’t change that, it only adds a bit of syntactic sugar. Although we definitely need better support for AsyncResults and AsyncOptions.

TLDR: Return statements breaks one of the main benefits of using ReScript, making data transformations and control flow explicit.

4 Likes

Finally a more tangetial thing on async results and async options:

Here’s a light weight pipeapable pattern from zth that’s really helpful and that it might solve what you are looking for without return statements or flatMaps

2 Likes

Perhaps a simpler framing is: do you think you’d benefit from early returns?
Possible answers ranging from Yes and its fantastic, to the other estreme: no and I strongly believe that nobody else should use them whether or not they want.

2 Likes

Just my humble opinion here. I tend to like using early return, probably because I like Rust and I also see the same concept in the generated JS code. Said that, I would understand a disagreement based on the how things are handled from a functional standpoint.

But

I would like to have to have coherency in the language respect to let ?. If I understand correctly, let ? can be seen as an implicit way of expressing an early return. I strongly believe that introducing that syntax without an explicit way of expressing an early return would only make the language less coherent – I would probably prefer to not have let ? without an explicit way of writing an early return. In fact, one could use Result.mapError and let ? just to flatten the control flow, obtaining a hybrid version that still work. Something like this:

let runImport = (~readFile, ~parse, ~lookupUser, path) => {
  let? Ok(contents) =
    readFile(path)->Result.mapError(_ => "Could not read file")
  let? Ok(payload) = parse(contents)
  let? Ok(user) = lookupUser(payload.userId)->OptionExt.okOr("Unknown user")
  let? Ok() =
    (user.isFree && payload.size > freeLimit)
      ->BoolExt.okOr("payload exceeds free tier limit")

  switch authorizeImport(user, payload.accountId) {
  | Denied(reason) => Error(reason)
  | Allowed(token) =>
    Audit.log("import-start", user.id)
    persistImport(~user, ~token, ~payload)
  }
}

Playground

To me it would be strange in this situation to not have an explicit way of early returning, just because we have that with the let? syntax. Not in this particular scenario, but in general one would have be tempted to expose something like:

module AuthorizeImportReturn = {
  let toResult = value =>
    switch value {
    | Allowed(token) => Ok(token)
    | Denied(reason) => Error(reason)
    }
}

In order to rely on let? only to create an early return:

let? Ok(token) =
  authorizeImport(user, payload.accountId)->AuthorizeImportReturn.toResult
Audit.log("import-start", user.id)
persistImport(~user, ~token, ~payload)

Is it just me? What do you think?

2 Likes

I tend to agree - I feel like let ? is syntax sugar for early return without the base syntax. I brought it up in my post because its an experimental feature that is already shipped in v12 and I wanted to motivate what an explicit early return allows you to do beyond the let ? syntax.

I think I come from the same place as you in that I’m used to these constructs in writing rust day to day and I find them helpful in keeping code readable. Since the type checker still enforces uniform return types I don’t find it to be a footgun in the same way as dynamic languages.

Those concepts have some overlap, but they are not the same. If we had only return and no let?, your example would look somewhat like this:

let runImport = (~readFile, ~parse, ~lookupUser, path) => {
  let contents = switch readFile(path) {
  | Error(_) => return Error("Could not read file")
  | Ok(contents) => contents
  }

  let payload = switch parse(contents) {
  | Error(error) => return Error(error)
  | Ok(payload) => payload
  }

  let user = switch lookupUser(payload.userId) {
  | None => return Error("Unknown user")
  | Some(user) => user
  }

  if user.isFree && payload.size > freeLimit {
    return Error("payload exceeds free tier limit")
  }

  let token = switch authorizeImport(user, payload.accountId) {
  | Denied(reason) => return Error(reason)
  | Allowed(token) => token
  }

  Audit.log("import-start", user.id)
  persistImport(~user, ~token, ~payload)
}

If we use both concepts:

let runImport = (~readFile, ~parse, ~lookupUser, path) => {
  let? Ok(contents) = readFile(path)->Result.mapError(_ => "Could not read file")
  let? Ok(payload) = parse(contents)
  let? Ok(user) = lookupUser(payload.userId)->OptionExt.okOr("Unknown user")

  if user.isFree && payload.size > freeLimit {
    return Error("payload exceeds free tier limit")
  }

  let token = switch authorizeImport(user, payload.accountId) {
  | Denied(reason) => return Error(reason)
  | Allowed(token) => token
  }

  Audit.log("import-start", user.id)
  persistImport(~user, ~token, ~payload)
}

I would not mind removing let? again, since it’s still marked experimental for the exact case to remove it later, but it would certainly ruffle some feathers already.

While many examples are about transforming data from a to b to c and return early on the unhappy path, what about the opposite?

Example of a naive markdown parser in js:

let parseLine = line => {
  if (line === "") {
    return { type: "empty" }
  }

  const heading = /^(#{1,6}) (.+)$/.exec(line)

  if (heading) {
    return { type: "heading", level: heading[1].length }
  }

  const hr = /^---$/.exec(hr)

  if (hr) {
    return { type: "hr" }
  }

  // ...
}

In rescript I wrote something like this, because a simple if else or switch doesn’t work as i need some information from the regex result:

let parseEmpty = line => {
  if line === "" {
    Some(Empty)
  } else {
    None
  }
}

let parseHeading = line => {
  let heading = RegExp.exec(/^(#{1,6}) (.+)$/, line)

  switch heading {
  | Some([_, Some(marker), Some(content)]) =>
    Some(
      Heading({
        level: marker->String.length,
        children: Inline.parseInline(content),
      }),
    )
  | _ => None
  }
}

let parseHR = line => {
  let hr = RegExp.exec(/^(\s*)---$/, line)

  switch hr {
  | Some([_, Some(indent)]) => Some(HR({indent: indent}))
  | _ => None
  }
}

let findFirst = arr => Array.findMap(arr, f => f())

let parseLine = line => findFirst([
  () => parseEmpty(line),
  () => parseHeading(line),
  () => parseHR(line),
  // ...
])
1 Like

Great example!

In general I like to nudge the coder into breaking stuff up into multiple functions/let-bindings and compose them together. But the extra array allocation and mapping over should not be necessary so I am also in favor of early return in this example.

However, this case is also covered by let? as well, because you can just make None the case where you continue:

let parseLine = line => {
  let? None = parseEmpty(line)
  let? None = parseHeading(line)
  parseHR(line)
}

It’s just a little weird when you come from JS and certainly unintuitive.

1 Like

Oh, I see. Changed my code directly. Thanks :slight_smile:

1 Like

Lol I didn’t even know that was a thing, likely the most counterintuitive use of let? :joy:

let? None = parseEmpty(line)
3 Likes