Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Elixir: How to avoid deeply nested case statements?

Tags:

elixir

Let's say I have a function main_function that depends on the result of three other functions which can each either return {:ok, result} or {:error, error}. How can I avoid having deeply nested case statements that feel like callback hell in javascript.

Example:

def func1(input) do
  case SOMECONDITION do
    {:ok, result} ->
      {:ok, result}
    {:error, error} ->
      {:error, error}
  end
end

def func2(input) do
  case SOMECONDITION do
    {:ok, result} ->
      {:ok, result}
    {:error, error} ->
      {:error, error}
  end
end

def func3(input) do
  case SOMECONDITION do
    {:ok, result} ->
      {:ok, result}
    {:error, error} ->
      {:error, error}
  end
end

def main_function(input) do
  case func1(input) do
    {:ok, result} ->
      case func2(result) do
        {:ok, result} ->
          case func3(result) do
            {:ok, result} ->
              {:ok, EXPECTED_OUTCOME}
            {:error, error} ->
              {:error, error}
          end
        {:error, error} ->
          {:error, error}
      end
      {:error, error} ->
        {:error, error}  
    end
  end
end

This just doesn't feel right...

like image 966
Ole Spaarmann Avatar asked Jun 13 '18 00:06

Ole Spaarmann


2 Answers

[Edit]: I've added a convenience link to a great talk that covers this concept, as well as solutions for more sophisticated needs from ElixirConf 2018 below.

Don't worry - Elixir has you covered. You want this special form: with/1

with/1 will continue executing functions if and only if they match the expected outcome.

You're main function essentially looks something like:

def main_function(input) do
  with {:ok, result_1} <- func1(input),
       {:ok, result_2} <- func2(result_1),
        ...,
  do: {:ok, EXPECTED_OUTCOME}
end

When it can't find a match, say because there's a tuple like {:error, _error} the special form will return the first error encountered and stop executing the functions.

You can also add an else condition. An example where I've used this is when a user may expect some action that requires a lot of functions to complete, and I want to alert them of the same thing regardless of where it failed:

def main_function(input) do
  with {:ok, result_1} <- func1(input),
       {:ok, result_2} <- func2(result_1),
        ... do
    {:ok, EXPECTED_OUTCOME}
  else
    _error ->
      {:error, "Couldn't complete action"}
  end
end

Additional Resources:

Here's an amazing talk from the author of Credo on this concept, courtesy of ElixirConf 2018: https://www.youtube.com/watch?v=ycpNi701aCs&t=473s

Here's a great post on with/1: https://www.erlang-solutions.com/blog/exploring-with-the-elixir-special-form.html

like image 162
The Brofessor Avatar answered Oct 14 '22 13:10

The Brofessor


Answering the comment on the accepted answer:

That is an interesting approach. But let's say func1 and func2 are writing to the db. If func1 returns an error I don't want func2 to be executed at all. Would this be the case?

There is also another option, if you are using Ecto.

Let's say func1, func2, and func3 all write to the DB, but the steps fail at func2, which means func1 has already written to the DB.

And let's say they return {:ok, result} or {:error, error}.

Ecto.Multi to the rescue!

alias Ecto.Multi
alias MyApp.Repo

def main_function(input) do
  result =
    Multi.new()
    |> Multi.run(:result1, fn _ -> func1(input) end)
    |> Multi.run(:result2, &func2(&1.result1))
    |> Multi.run(:result3, &func3(&1.result2))
    |> Repo.transaction()

  case result do
    {:ok, %{result1: _, result2: _, result3: _}} ->
      {:ok, EXPECTED_OUTCOME}

    {:error, error, _changes_so_far} ->
      {:error, error}
  end
end

With Multi, returning an error tuple will abort any further operations and make the whole multi fail. Also, since it's using a transaction, any that succeeded will all be rolled back.

like image 38
ryanwinchester Avatar answered Oct 14 '22 13:10

ryanwinchester