“I call it my billion-dollar mistake. It was the invention of the null reference in 1965. At that time, I was designing the first comprehensive type system for references in an object-oriented language (ALGOL W). My goal was to ensure that all use of references should be absolutely safe, with checking performed automatically by the compiler. But I couldn’t resist the temptation to put in a null reference, simply because it was so easy to implement. This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years.”

— Tony Hoare

A null or nil represents an absence of value, which necessitates handling two distinct code paths: one where a value is present and one where it is absent. In most functional programming languages, this is managed using the Option monad, which encapsulates the idea of Some or None in a single context.

Elixir, although functional, does not have monads in the traditional sense. Instead, by convention, it uses tagged tuples to resolve multiple paths in a single context. The typical pattern in Elixir is {:ok, value} for success and {:error, reason} for failure, often representing None as {:error, :not_found}, treating None as an Either error. However, since None is the absence of a value, a more accurate representation is {:ok, value} or {:none}. Both approaches achieve the same effect; just make sure to consistently apply the same pattern.

Exception Handling

The function get_player! returns a player and throws an exception if none is found:

@spec get_player!(integer()) :: Player.t()
def get_player!(id), do: Repo.get!(Player, id)

This is a typical example of hidden control flow, which I avoid. Fortunately, Ecto provides Repo.get, which never errors.

Nil Issues

@spec get_player(integer()) :: Player.t() | nil
def get_player(id), do: Repo.get(Player, id)

While this solves the hidden control flow problem, it introduces the infamous null problem.

Either

@spec get_player(integer()) :: {:ok, Player.t()} | {:error, :not_found}
def get_player(id) do
  case Repo.get(Player, id) do
    nil -> {:error, :not_found}
    player -> {:ok, player}
  end
end

This solves the null issue by treating the response as an error that reports “not found.” However, this isn’t truly an error; it’s simply an absence of value.

Option

@spec get_player(integer()) :: {:ok, Player.t()} | {:none}
def get_player(id) do
  case Repo.get(Player, id) do
    nil -> {:none}
    player -> {:ok, player}
  end
end

This accurately represents an Option, but note it is not as typical in Elixir as using the error tuple pattern.

DRYing Things Out

We can reuse get_player in our update_player function.

@spec update_player(Player.t(), map()) :: {:ok, Player.t()} | {:error, Ecto.Changeset.t()} | {:none}
def update_player(%Player{} = player, attrs) do
  with {:ok, current_player} <- get_player(player.id),
       changeset = Player.update_changeset(current_player, attrs),
       result <- Repo.update(changeset) do
    result
  else
    {:none} -> {:none}
  end
end

If you have experience in functional programming, you might notice that else {:none} -> {:none} looks odd, as it is simply passing through the none value. This is pattern matching (fold), when we want something closer to a functor, which transforms the value within the context. Fortunately, Elixir has a with construct which comes close.

@spec update_player(Player.t(), map()) :: {:ok, Player.t()} | {:error, Ecto.Changeset.t()} | {:none}
def update_player(%Player{} = player, attrs) do
  with {:ok, current_player} <- get_player(player.id) do
    changeset = Player.update_changeset(current_player, attrs)
    Repo.update(changeset)
  end
end

Now it fits a more typical functional pattern, where you only make changes within the context of a value. If the value is not present, it passes through the error or none.

Note that in another functional language, you might solve this by nesting the Option monad inside an Either, but Elixir doesn’t have monads, so this is probably overkill. The important thing is to remember that Elixir is dynamically typed, so it is important to pick a pattern and be consistent throughout the codebase.

Updating Downstream Code

Phoenix’s code generator does a nice job of creating boilerplate LiveView code.

Get Player

@impl true
def handle_params(%{"id" => id}, _, socket) do
  {:noreply,
   socket
   |> assign(:page_title, page_title(socket.assigns.live_action))
   |> assign(:player, Accounts.get_player!(id))}
end

In the code above, the function handle_params gets the id from the URL and fetches the player record with the matching id. The function get_player! throws an error when the id does not match an existing record. Note that this is using a hidden control flow, which is ignored, causing it to fall through to the Supervisor, which restarts the process and displays an error page.

However, this is a local error, we know it will happen and can generate a reasonable recovery path rather than just “Let it crash.”

def handle_params(%{"id" => id}, _, socket) do
  case Accounts.get_player(id) do
    {:ok, player} ->
      {:noreply,
       socket
       |> assign(:page_title, page_title(socket.assigns.live_action))
       |> assign(:player, player)}

    {:none} ->
      {:noreply,
       socket
       |> put_flash(:error, "Player not found")
       |> push_redirect(to: "/players")}
  end
end

In this updated version, we use get_player, which codifies that a player record may or may not exist. If it exists, the code continues with the previous path. If it does not, it redirects the user to the list view with a flash message saying “Player not found.”

Update Player

defp save_player(socket, :edit, player_params) do
  case Accounts.update_player!(socket.assigns.player, player_params) do
    player ->
      notify_parent({:saved, player})

      {:noreply,
       socket
       |> put_flash(:info, "Player updated successfully")
       |> push_patch(to: socket.assigns.patch)}
  end
end

Notice that this only has “Player updated successfully,” but the ! suggests a hidden path, which is ignored. Like the previous code, this can be replaced with logic that has no hidden control paths.

defp save_player(socket, :edit, player_params) do
  case Accounts.update_player(socket.assigns.player, player_params) do
    {:ok, player} ->
      notify_parent({:saved, player})

      {:noreply,
       socket
       |> put_flash(:info, "Player updated successfully")
       |> push_patch(to: socket.assigns.patch)}

    {:none} ->
      {:noreply,
       socket
       |> put_flash(:error, "Player not found")
       |> push_redirect(to: "/players")}

    {:error, %Ecto.Changeset{} = changeset} ->
      {:noreply,
       socket
       |> assign_form(changeset)}
  end
end

This updated version handles all three code paths: either the player record is successfully updated, another process just deleted the player record so it is missing, or another process just updated the record, which caused this update to no longer be valid.