Absence in Elixir: Gracefully avoiding `nil`
“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.