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.