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.