Idea: Rust-like syntactic support for :ok/:error

Rust is too low-level for most of my work, but I’m envious of the strong fp influence in its design. E.g., the verbose way to react to an error — and bubble it up — looks like this. The gen2() function also returns this Result Union/Either type:

fn caller() -> Result<String, String> {
    let r = gen2();
    match r {
        Ok(i) => Ok(i.to_string()),
        Err(e) => Err(e),
    }
}

To me, that looks a lot like the usual Elixir way of matching on {:ok, ...} and {:error, ...}. Rust, though, has this nice UX syntactic support with the ? operator. This code is equivalent to the above:

fn caller() -> Result<String, String> {
    let n = gen2()?;
    Ok(n.to_string())
}

(Source: Rust Error Handling. How to safely and properly handle… | by Abhishek Gupta | Better Programming)

Pretty damn nice, IMO. When I was doing a lot of Rust coding, error handling was a breezy, low-boilerplate experience — yet the code was as robust as ever. Invocations with ? like above can be chained together with the . operator.

I’m imagining syntactic support like this that’d work seamlessly with the existing :ok/:error convention, just like the Rust syntax works with Result.

Brainstorming and adapting the example from Writing Predictable Elixir Code with Reducers | AppSignal Blog … here’s the original code:

options = %{conn: conn}
 
with {:ok, payment} <- fetch_payment_information(params, options),
     {:ok, user}    <- fetch_user(conn),
     {:ok, address} <- fetch_address(%{user: user, params: params}, options),
     {:ok, order}   <- create_order(%{user: user, address: address, payment: payment}, options)
  do
  conn
  |> put_flash(:info, "Order completed!")
  |> redirect(to: Routes.order_path(conn, order))
else
  {:error, error_description} ->
    conn
    |> put_flash(:error, parse_error(error_description))
    |> render("checkout.html")
end

Let’s say we want to refactor this, extracting the with clause to a new function, but use a new => operator:

def process_payment(params, options) do
  payment <= fetch_payment_information(params, options),
  user    <= fetch_user(conn),
  address <= fetch_address(%{user: user, params: params}, options),

  order   <= create_order(%{user: user, address: address, payment: payment}, options)
end

Like Rust’s ?, this <= would automatically “unwrap” the return values, directly giving access to the :ok result. But if any of these returns :error it’ll automatically return the first found :error result. In a nutshell, it’d de-sugar to a with/else-construct.

The refactored caller could be standard un-sugared Elixir since it’s top-level and wants to handle the error, not bubble it up. But now it’s more readable:

options = %{conn: conn}
 
case process_payment(params, options) do
  {:ok, order} do
    conn
    |> put_flash(:info, "Order completed!")
    |> redirect(to: Routes.order_path(conn, order))
  {:error, error_description} ->
    conn
    |> put_flash(:error, parse_error(error_description))
    |> render("checkout.html")
end

This has been vetted and used in Rust for years. So even though it hides some info and does some things automatically, I think the Rust experience has validated it as a positive addition to the language.

Along with <= I was thinking about an unwrapping version of the pipe |>: This new version would transparently handle functions that return the :ok/:error tuple, enabling them to be used in pipelines with functions that don’t. IMO this second syntax could make code even cleaner, e.g. if the new pipe is !>:

username
|> to_lower
!> get_user_from_database
|> format_address

While with is a little more verbose, it has the benefit of being more explicit and more flexible, in that you can match on anything - not just an ok tuple.

F# has the result computation expression which I loved because I found working with the result monad to be a total PITA without it.

1 Like

One thing that is often missed at a glance with with is the inability to specify ordered fallback handling. For example, say there is a response that is {:error, "arbitrary_error_string"} that is returned by two functions my_fun_1 and my_fun_2. However, I as the application developer want two different rollback operations based on if my_fun_1 fails or if my_fun_2 fails, and they’re part of two separate 3rd party libraries, so I have no control over what they return (a scenario I’ve ran into). They both happen to return an error tuple that lacks uniqueness.

What I see lacking from this feature is something similar: a generic ordered error handler. However, it seems to have a much higher potential to fulfill that void if error handler functions were added:

payment <= fetch_payment_information(params, options), &handle_fetch_payment_error/1
user    <= fetch_user(conn), &handle_fetch_user_error/1
address <= fetch_address(%{user: user, params: params}, options), &handle_fetch_address_error/1
order   <= create_order(%{user: user, address: address, payment: payment}, options), &handle_create_order_error/1

But in theory this would just rely on pattern matching in the head of the handle_x_error functions, which is already a well defined feature of the language:

user = fetch_user(conn) |> handle_fetch_user_error()

def handle_fetch_user_error({:error, db_error_struct}) do
...
end
def handle_fetch_user_error({:ok, user}), do: user
1 Like

I don’t follow having “no control” - what about wrapping them in private functions?

Two thoughts:

  • “cleaner” is subjective, IMO the ! vs | visual distinction is very small
  • you can already write functions like this if you want with plain |>, but it means that downstream functions all grow a {:error, _} pass-through case
3 Likes

Elixir is sort of categorically different from rust in that failure is more than just a option, it’s a strategy. Having error tuples exposed and handled is a signal that you can tolerate an error in this location and you’re willing to deal with it, versus triggering a crash and giving up. Hiding that, IMO, loses a signal that qualitatively tells you something about the code that is flowing through your system.

There’s a couple of libraries that provide macros towards this goal. Off the top of my head OK Elixir – ok v0.2.0 might be be appealing?

2 Likes

True, wrapping them in private functions would have been differentiable.

Can’t you use something like the following to discriminate between error tuples of my_fun_1 and my_fun_2?

with {:mf1, {:ok, _}} <- my_fun_1(...),
     {:mf2, {:ok, _}} <- my_fun_2(...) do
  ...whatever...
else
  {:mf1, {:error, _}} -> rollback_my_fun_1(...)
  {:mf2, {:error, _}} -> rollback_my_fun_2(...)
end
1 Like

These are both valid workarounds, but they still work-around a lack of syntax for ordered error handling in with. This could be out of scope for with, having to create a bunch of transient terms to shoe-horn this into a language feature could be just abusing the feature. The original proposal has the potential to make ordered error handling cleaner.