Good and Bad Elixir
June 2, 2021
I’ve seen a lot of elixir at this point, both good and bad. Through all of that code, I’ve seen similar patterns that tend to lead to worse code. So I thought I would document some of them as well as better alternatives to these patterns.
Map.get/2 and Keyword.get/2 vs. Access
Map.get/2
and Keyword.get/2
lock you into using a specific data structure. This means that if you want to change the type of structure, you now need to update all of the call sites. Instead of these functions, you should prefer using Access:
# Don't do
opts = %{foo: :bar}
Map.get(opts, :foo)
# Do
opts[:foo]
Don’t pipe results into the following function
Side-effecting functions tend to return “results” like {:ok, term()} | {:error, term()}
. If your dealing with side-effecting functions, don’t pipe the results into the next function. It’s always better to deal with the results directly using either case
or with
.
# Don't do...
def main do
data
|> call_service
|> parse_response
|> handle_result
end
defp call_service(data) do
# ...
end
defp parse_response({:ok, result}, do: Jason.decode(result)
defp parse_response(error, do: error)
defp handle_result({:ok, decoded}), do: decoded
defp handle_result({:error, error}), do: raise error
# Do...
def main do
with {:ok, response} <- call_service(data),
{:ok, decoded} <- parse_response(response) do
decoded
end
end
Using pipes forces our functions to handle the previous function’s results, spreading error handling throughout our various function calls. The core problem here is subtle, but it’s essential to internalize. Each of these functions has to know too much information about how it is being called. Good software design is mainly about building reusable bits that can be arbitrarily composed. In the pipeline example, the functions know how they’re used, how they’re called, and what order they’re composed.
Another problem with the pipeline approach is that it tends to assume that errors can be handled generically. This assumption is often incorrect.
When dealing with side-effects, the only function with enough information to decide what to do with an error is the calling function. In many systems, the error cases are just as important - if not more important - than the “happy path” case. The error cases are where you’re going to have to perform fallbacks or graceful degradation.
If your in a situation where errors are a vital part of your functions control flow, then it’s best to keep all of the error handling in the calling function using case
statements.
# Do...
def main(id) do
case :fuse.check(:service) do
:ok ->
case call_service(id) do
{:ok, result} ->
:ok = Cache.put(id, result)
{:ok, result}
{:error, error} ->
:fuse.melt(:service)
{:error, error}
end
:blown ->
cached = Cache.get(id)
if cached do
{:ok, result}
else
{:error, error}
end
end
end
This increases the size of the calling function, but the benefit is that you can read this entire function and understand each control flow.
Don’t pipe into case statements
I used to be on the fence about piping into case statements, but I’ve seen this pattern abused too many times. Seriously y’all, put down the pipe operator and show a little restraint. If you find yourself piping into case
, it’s almost always better to assign intermediate steps to a variable instead.
# Don't do...
build_post(attrs)
|> store_post()
|> case do
{:ok, post} ->
# ...
{:error, _} ->
# ...
end
# Do...
changeset = build_post(attrs)
case store_post(changeset) do
{:ok, post} ->
# ...
{:error, _} ->
# ...
end
Don’t hide higher-order functions
Higher-order functions are great, so try not to hide them away. If you’re working with collections, you should prefer to write functions that operate on a single entity rather than the collection itself. Then you can use higher-order functions directly in your pipeline.
# Don't do...
def main do
collection
|> parse_items
|> add_items
end
def parse_items(list) do
Enum.map(list, &String.to_integer/1)
end
def add_items(list) do
Enum.reduce(list, 0, & &1 + &2)
end
# Do...
def main do
collection
|> Enum.map(&parse_item/1)
|> Enum.reduce(0, &add_item/2)
end
defp parse_item(item), do: String.to_integer(item)
defp add_item(num, acc), do: num + acc
With this change, our parse_item
and add_item
functions become reusable in the broader set of contexts. These functions can now be used on a single item or can be lifted into the context of Stream
, Enum
, Task
, or any number of other uses. Hiding this logic away from the caller is a worse design because it couples the function to its call site and makes it less reusable. Ideally, our APIs are reusable in a wide range of contexts.
Another benefit of this change is that better solutions may reveal themselves. In this case, we may decide that we don’t need the named functions and can use anonymous functions instead. We realize that we don’t need the reduce
and can use sum
.
def main do
collection
|> Enum.map(&String.to_integer/1)
|> Enum.sum()
end
This final step may not always be the right choice. It depends on how much work your functions are doing. But, as a general rule, you should strive to eliminate functions that only have a single call site. Even though there are no dedicated names for these functions, the final version is no less “readable” than when we started. An Elixir programmer can still look at this series of steps and understand that the goal is to convert a collection of strings into integers and then sum those integers. And, they can realize this without needing to read any other functions along the way.
Avoid else
in with
blocks
else
can be helpful if you need to perform an operation that is generic across all error values being returned. You should not use else
to handle all potential errors (or even a large number of errors).
# Don't do...
with {:ok, response} <- call_service(data),
{:ok, decoded} <- Jason.decode(response),
{:ok, result} <- store_in_db(decoded) do
:ok
else
{:error, %Jason.Error{}=error} ->
# Do something with json error
{:error, %ServiceError{}=error} ->
# Do something with service error
{:error, %DBError{}} ->
# Do something with db error
end
For the same reason, under no circumstances should you annotate your function calls with a name just so you can differentiate between them.
with {:service, {:ok, resp}} <- {:service, call_service(data)},
{:decode, {:ok, decoded}} <- {:decode, Jason.decode(resp)},
{:db, {:ok, result}} <- {:db, store_in_db(decoded)} do
:ok
else
{:service, {:error, error}} ->
# Do something with service error
{:decode, {:error, error}} ->
# Do something with json error
{:db, {:error, error}} ->
# Do something with db error
end
If you find yourself doing this, it means that the error conditions matter. Which means that you don’t want with
at all. You want case
.
with
is best used when you can fall through at any point without worrying about the specific error or contrary pattern. A good way to create a more unified way to deal with errors is to build a common error type like so:
defmodule MyApp.Error do
defexception [:code, :msg, :meta]
def new(code, msg, meta) when is_binary(msg) do
%__MODULE__{code: code, msg: msg, meta: Map.new(meta)}
end
def not_found(msg, meta \\ %{}) do
new(:not_found, msg, meta)
end
def internal(msg, meta \\ %{}) do
new(:internal, msg, meta)
end
end
def main do
with {:ok, response} <- call_service(data),
{:ok, decoded} <- decode(response),
{:ok, result} <- store_in_db(decoded) do
:ok
end
end
# We wrap the result of Jason.decode in our own custom error type
defp decode(resp) do
with {:error, e} <- Jason.decode(resp) do
{:error, Error.internal("could not decode: #{inspect resp}")}
end
end
This error struct provides a unified way to surface all of the errors in your application. The struct can render errors in a phoenix controller or be returned from an RPC handler. Because the struct your using is an exception, the caller can also choose to raise the error, and you’ll get well-formatted error messages.
case main() do
{:ok, _} -> :ok
{:error, e} -> raise e
end
State what you want, not what you don’t
You should be intentional about your function’s requirements. Don’t bother checking that a value is not nil
if what you expect it to be is a string:
# Don't do...
def call_service(%{req: req}) when not is_nil(req) do
# ...
end
# Do...
def call_service(%{req: req}) when is_binary(req) do
# ...
end
The same is true for case
statements and if
statements. Be more explicit about what it is you expect. You’d prefer to raise or crash if you receive arguments that would violate your expectations.
Only return error tuples when the caller can do something about it.
You should only force your user to deal with errors that they can do something about. If your API can error, and there’s nothing the caller can do about it, then raise an exception or throw. Don’t bother making your callers deal with result tuples when there’s nothing they can do.
# Don't do...
def get(table \\ __MODULE__, id) do
# If the table doesn't exist ets will throw an error. Catch that and return
# an error tuple
try do
:ets.lookup(table, id)
catch
_, _ ->
{:error, "Table is not available"}
end
end
# Do...
def get(table \\ __MODULE__, id) do
# If the table doesn't exist, there's nothing the caller can do
# about it, so just throw.
:ets.lookup(table, id)
end
Raise exceptions if you receive invalid data.
You should not be afraid of just raising exceptions if a return value or piece of data has violated your expectations.
If you’re calling a downstream service that should always return JSON, use Jason.decode!
and avoid writing additional error handling logic.
# Don't do...
def main do
{:ok, resp} = call_service(id)
case Jason.decode(resp) do
{:ok, decoded} ->
decoded
{:error, e} ->
# Now what?...
end
end
# Do...
def main do
{:ok, resp} = call_service(id)
decoded = Jason.decode!(resp)
end
This allows us to crash the process (which is good) and removes the useless error handling logic from the function.
Use for
when checking collections in tests
This is a quick one, but it makes your test failures much more helpful.
# Don't do...
assert Enum.all?(posts, fn post -> %Post{} == post end)
# Do...
for post <- posts, do: assert %Post{} == post