Seamless Notes

Keep the happy path flat

Validate inputs and propagate errors without nesting the successful path.

In Don’t let it crash, we saw how Seamless makes recoverable failure explicit in return types. Result and Maybe tell callers when an operation can fail. But what happens when several fallible operations depend on one another?

Consider a route handler: it needs to recognize a path, parse an ID, and build a route. Handling each result with a case and placing the next operation inside its successful branch pushes the route construction further to the right. This growing indentation is often called the pyramid of doom.

Those successful steps form the happy path. Seamless lets us write them in a flat block while giving each failure a defined outcome.

To illustrate, let’s build a small router one step at a time. A route for /users/42 needs two path segments. The first must be "users"; the second contains the user ID. We start by extracting that second segment.

This function returns Ok(raw_id) when the path matches, or Err(message) when it doesn’t:

@type (List(String)) -> Result(String, String)
def raw_user_id(segments) {
  case segments {
    ["users", raw_id] -> Ok(raw_id)
    _ -> Err("expected /users/:id")
  }
}

The pattern checks the length of the list and its first element, then binds the second element to raw_id. For /users/42, that value is "42". Paths such as /teams/42 or /users/42/edit take the error branch.

Match and continue

With let ... else, we can move the successful branch into the rest of the function:

@type (List(String)) -> Result(String, String)
def raw_user_id(segments) {
  let ["users", raw_id] = segments else Err("expected /users/:id")
  Ok(raw_id)
}

When the pattern matches, execution continues with raw_id in scope. The rest of the block takes the place of the successful branch in the original case. If the pattern fails to match, the else expression becomes the result of the block, and the remaining lines are skipped.

A pattern that can fail to match is called refutable. Here, let checks the path shape and binds the value we need next.

Chain the parsing steps

The raw_user_id function checks the path and returns the ID as a string. For this example, assume UserId.parse returns Result(UserId, String), and Route.User(id) constructs a user route. Each successful result feeds the next step:

@type (List(String)) -> Result(Route, String)
def route(segments) {
  case raw_user_id(segments) {
    Ok(raw_id) ->
      case UserId.parse(raw_id) {
        Ok(id) -> Ok(Route.User(id))
        Err(error) -> Err(error)
      }
    Err(error) -> Err(error)
  }
}

Both case expressions do the same work: extract the value from Ok and continue, or propagate Err.

Chain syntax expresses that sequence with <- bindings:

@type (List(String)) -> Result(Route, String)
def route(segments) {
  raw_id <- raw_user_id(segments)
  id <- UserId.parse(raw_id)
  Ok(Route.User(id))
}

For a Result, read value <- operation() as: run the operation, bind its Ok value, and continue. If it produces Err, that error becomes the result of the block and the remaining expressions are skipped.

A path mismatch stops the chain before UserId.parse runs. A parsing error stops it before the route is constructed. When both steps succeed, the final expression produces Ok(Route.User(id)). The whole function still returns Result(Route, String).

The binding operator determines what gets bound. With let parsed = UserId.parse(raw_id), parsed holds the complete Result(UserId, String). With id <- UserId.parse(raw_id), id holds the successful UserId, and the binding handles error propagation.

With Maybe, the same syntax follows Some values and stops at None.

Sequence and match in one binding

Now let the router accept a path string. Suppose our application provides Path.parse(input), which validates the path and returns its segments as Result(List(String), String).

We can put the path check directly in the router, after getting the segments:

segments <- Path.parse(input)
let ["users", raw_id] = segments else Err("expected /users/:id")

We can combine those two steps in one binding:

@type (String) -> Result(Route, String)
def route(input) {
  let ["users", raw_id] <- Path.parse(input) else Err("expected /users/:id")
  id <- UserId.parse(raw_id)
  Ok(Route.User(id))
}

The <- gets the successful value from Path.parse. The let pattern matches that value.

An error from Path.parse passes through unchanged. The else fallback runs only when parsing succeeds but the segments have the wrong shape. After a successful match, the next line parses the ID.

A refutable pattern in a binding requires let. A bare <- binding accepts a name or an irrefutable pattern: one guaranteed to match the successful value.

Add a guard

A guard adds a condition to a pattern. For example, we can require the ID segment to be non-empty:

let ["users", raw_id] when raw_id != "" = segments else Err("expected /users/:id")

The guard runs after the pattern matches, with raw_id in scope. A false guard takes the same fallback as a failed match. Guards also work with let pattern <- expression.

When you can omit else

Chaining and pattern mismatch are separate mechanisms. Chaining is selected from the type of the expression being sequenced. If a pattern fails and there is no else, the surrounding block’s result type must supply mismatch behavior.

When that behavior is available, else is optional. In a block producing Maybe, a mismatch produces None. With Result, it produces an Err if the error type supports conversion from a pattern mismatch. A conversion to String is provided.

An explicit else lets us choose the error message, as the router does with "expected /users/:id".

The same rule applies in a SmUnit test. As Macros Without Magic showed, a test body produces a Check. That result type supplies the mismatch behavior here, so a failed match becomes a failed check:

test "a user path contains an ID" {
  let Ok(["users", raw_id]) = Path.parse("/users/42")
  assert raw_id == "42"
}

This pattern checks both successful parsing and the expected path shape. If either check fails, SmUnit reports the mismatch and skips the assertion.

The test and the router follow the same structure: check a value, then continue with the code that uses it. A new validation step fits before the operation that depends on it. Each failure still has a defined outcome, while the successful steps remain in execution order at one level of indentation.