Stratus3D

A blog on software engineering by Trevor Brown

The Problem With Elixir’s with

Summary: Return values from different expressions in a with block can only be distinguished by the shape of the data returned. This creates ambiguity in with 's else clauses that make code harder to understand and more challenging to modify correctly.

with has existed in Elixir since version 1.2 and is widely used. But there are downsides to this construct. In this blog post I’ll explore the problems caused by use of with in a codebase. I’ll assume you know how with works and have read the docs so I will skip over the basics of how it works.

The Problems

In Elixir with is widely used and is often relied on for it’s short-circuiting behavior. Below is a typical real-world use of it.

  def encode_with_schema(message, schema) do
    with {:ok, parsed_schema} <- parse_schema(schema),
         {:ok, encoded_message} <- encode_message(parsed_schema, message) do
      generate_checksum(encoded_message)
    else
      {:error, _msg} ->
        {:error, :schema_unparseable}
      {:error, _msg, _value} ->
        {:error, :message_invalid}
    end
  end

Before I dive into the problem created by with blocks, I want you to pause, read through the code above, and see if you can match up each pattern in the else block with each expression in the head of the with block.

Were you able to figure it out?

You probably realized you can make an educated guess at which else clauses correspond to which expression in the with. But you can’t be certain without knowing what each expression to the right of the arrows in the with block could return. This is a key charactistic of with blocks. with blocks are the only Elixir construct that implicitly uses the same else clauses to handle return values from different expressions.

This is really the core issue with with. The lack of a one-to-one correspondence between an expression in the head of the with block and the clauses that handle its return values makes it impossible to know when each else clause will be used. An else clause could handle return values from any expression in the head of the with.

Comprehension and Intent

As I’ve shown the else clauses makes understanding of existing code difficult. Additionally the intent of the original programmer is impossible to discern from the code alone. An else clause may end up handling a return value from a particular expression but it will not be clear if that is by design or by accident.

Unintended Behavior

Changing the return value of an existing expression in a with block (say a function) may result in a different else clause being executed. Take the example with block above. Suppose the encode_message/3 function was updated to return an {:error, reason} tuple instead of {:error, msg, value} tuple. The code would still be valid and the types correct, the compiler and Dialyzer would not complain, but the encode_with_schema/2 function would begin to return the incorrect {:error, :schema_unparseable} tuple instead of {:error, :message_invalid} when the message failed to encode.

Solutions

All the solutions listed here do not have the downsides listed above that with…​else has.

When Errors Cannot Be Handled Inside the Function

When your function cannot recover from an error on its own and the calling code may also not be able to do anything about the error raising an exception is the proper behavior. Sometimes large when blocks are better off as a sequence of functions that raise exceptions when they fail. The code shown at the beginning could be refactored to this:

  def encode_with_schema(message, schema) do
    schema
    |> parse_schema!()
    |> encode_message!(message)
    |> generate_checksum!(encoded_message)
  end

Notice how much more compact this function is! All because each function either returns the right value or raises an exception.

When Errors Need to be Handled on a Call by Call Basis

If you need to handle errors returned from each expression individually you should be using case and not with. A common complaint with case is that it makes code too deeply nested, but if you need more than two case statements in a single function that is a sign you need to break the function up into smaller functions. The code shown at the beginning could be refactored to this with the use of case:

  def encode_with_schema(message, schema) do
    case parse_schema(schema) do
      {:ok, parsed_schema} ->
         case encode_message(parsed_schema, message) do
           {:ok, encoded_message} -> generate_checksum(encoded_message)
           {:error, _msg, _value} -> {:error, :message_invalid}
        end
      {:error, _msg} -> {:error, :schema_unparseable}
    end
  end

The function seems a little more verbose, but it’s actually the same length as the original with block shown in this post, and there is no confusion about what each case clause handles.

If you need short-circuiting and don’t need to handle errors individually

There are two ways of doing this. One option is to use with without an else block. If you don’t use an else in your with blocks you’ll only be able to code for the happy path, and all errors will be returned to the calling function immediately. Of course the calling code will need to handle all possible return values but sticking to the {:error, reason} convention for errors can keep things simple.

The other option is to throw from each expression you want to be able to halt execution and then catch and return the thrown value. The code shown at the beginning of this post would look like this:

  def encode_with_schema(message, schema) do
    try do
      # these functions updated to throw an error instead of an exception
      schema
      |> parse_schema!()
      |> encode_message!(message)
      |> generate_checksum!(encoded_message)
    catch
      error -> error
    end
  end

Conclusion

There are sigificant downsides to with…​else and plenty of better alternatives. I think with is an overused construct in Elixir but it may be useful in certain circumstances. Hopefully this post has been thought provoking and has inspired you to consider other options before using a with block.