Untangling Nested Case Statements Using Elixir’s With Statement
I love spaghetti and meatballs. And when I first heard the term spaghetti code, it sounded tasty. Alas, it’s a pejorative phrase for code that is messy and overly complicated. If you write spaghetti code, no one will want to hire you.
That’s true in any language, including Elixir.
One way to introduce spaghetti code into your Elixir code base is with a nested case statement.
What is a nested case statement?
Below is a simple example of a double nested case statement. Don’t worry too much about making sense of the higher level context about what this code is doing. I took some actual production code from a project and altered it for educational purposes. Instead, if you need a higher level context, pretend we’re simply setting the is_snow_tire property to a boolean value based on the diameter property of a tire.
The whole point is to focus on the nested case aspect of this code.
It’s not too hard to reason about, but I still find myself stopping to wonder what the difference between the two error return clauses are and if they are actually doing anything different.
defmodule Car do
def undo_snow_tire(params) do
case get_tire_by_diameter(params["diameter"]) do
{:ok, tire} ->
changeset = Tire.tire_changeset(tire,
%{
is_snow_tire: false,
})
case Repo.update(changeset) do
{:ok, t} ->
{:ok, t}
{:error, changeset} ->
{:error, changeset}
end
{:error, error} -> {:error, error}
end
end
def get_tire_by_diameter(diameter), do: {:ok, "demo"}
end
Looking at the actual code upon which this was based off of, it actually doesn’t matter what the second element of the return error tuple is (I’m asking you to take my word for it in this instance as I don’t want to clutter up this post with more code that is not related to the concept we’re discussing).
So, what is wrong with a nested case statement?
I would argue there are 3 things wrong with it.
Point 1 – Readability
A nested case statement is simply not as readable as relying on pattern matching or using a with statement (coming later in this post). It’s harder for new developers coming into your team to understand your code base and make changes to it.
Point 2 – Maintainability and Adding New Features
The more you have to stop and think about what your code is doing, the harder it is to debug effectively and promptly. It’s also harder to add new features.
You want to be able to build a great product and have an easy time maintaining it. This will help you win against your competitors against the marketplace.
An extreme example for clarity
In case (pardon the pun) you didn’t believe the above example, here is an example of a nested case statement from a user in the elixir forum. I changed some of the variable names, but hopefully you can see my point about nested case statements.1
defmodule DemoModule do
def start_or_resume_user_session(session_id) when is_bitstring(session_id) do
case User.Supervisor.start_expression({"session", "global_id"}) do
{:ok, root_session} ->
case get_user_session(session_id) do
{:ok, user_session} ->
case get_latest_user_session_global_id(session_id, root_session) do
# I want to do stuff if it's nil
{:ok, nil} ->
case (root_session |> instance(user_session)) do
{:ok, {_, session}} ->
case session |> get_user_info do
{:ok, session_info} ->
case get_ib_global_id(session_info) do
{:ok, user_session_global_id} -> {:ok, user_session_global_id}
{:error, reason} -> {:error, reason}
end
{:error, reason} -> {:error, reason}
end
{:error, reason} -> {:error, reason}
end
# Return it if not nil
{:ok, existing_user_session_global_id} -> {:ok, existing_user_session_global_id}
{:error, reason} -> {:error, reason}
end
{:error, reason} -> {:error, reason}
end
{:error, reason} -> {:error, reason}
end
end
end
Notice how much harder this makes the code to read.
So what is is one to do? One solution is the with statement.
Syntax of a with statement
Let’s look at rewriting the double nested case statement using a with statement.
defmodule Car do
def undo_snow_tire(params) do
with {:ok, tire} <- get_tire_by_diameter(params["diameter"]),
{:ok, tire} <- Repo.update(Tire.changeset_for_update(tire, %{is_snow_tire: false})) do
{:ok, tire}
else
{:error, error} -> {:error, error}
end
end
def get_tire_by_diameter(diameter), do: {:ok, "demo"}
end
So the great thing about the with statement is that it lets you say “each intermediate result must match each pattern to the left of the “<-“ to execute the code in the with/do block, otherwise proceed to else”. Notice that your eye now can scan each statement to check what must match much more easily than it can with a nested case statement.
Advantages to using with
Advantage 1 – In our specific example, we must pattern match on the {:ok, tire} tuple for each function call. If a function should pattern match on a one element tuple like {:ok} instead, you can specify that too. Thus, the pattern match is very flexible.
Advantage 2 – You get can handle the same conditions as a nested case but with more concise code.
Advantage 3 – The with statement is an alternative way to do pattern matching on results and doing error handling. I almost think of it as an alternative to railway-oriented programming.
A Word on Elixir versions
Now this fellow named Scott notes that it’s not until Elixir 1.3 that you can use an else block in a with statement2. In Elixir 1.2, you only get the with/do block only.
Summary
I love spaghetti, but not in my code. Hopefully, this example of using the with statement will help you keep your codebase tidy too!
Footnotes
-
Source: https://elixirforum.com/t/case-pyramid-of-doom-nested-with-nested-happy-path/1407 ↩
<li id="fn:2">
<p>
<a href="http://www.scottmessinger.com/2016/03/25/railway-development-in-elixir-using-with/">Source: http://www.scottmessinger.com/2016/03/25/railway-development-in-elixir-using-with/</a> <a href="#fnref:2" class="reversefootnote">↩</a>
</p>
</li>