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.