Just Add Water
Or, Using Partial Application In Elixir for Better Errors.
Today, I was adapting the generator for a recipe website written by Hundred Rabbits into Elixir. While adapting their idiomatic C code into Elixir, I needed a way to pass a predefined list of ingredients to the builder API for Recipes.
To do this, I defined a function that returned a keyword listThe cheese: cheese
syntax looks a bit funny, but it’s Elixir syntax sugar for {:cheese, cheese}
where the second cheese
is the bound variable.:
def ingredients do
cheese = create_ingredient(...)
salt = create_ingredient(...)
[cheese: cheese, salt: salt]
end
And then in my builder API, I called that function inside the builder scope:
def recipes do
ingredients = Ingredients.ingredients()
...
end
In order to add an ingredient to a recipe, I used a function add_ingredient_quantity/3
, used something like this:
def recipes do
ingredients = Ingredients.ingredients()
create_recipe("salted cheese")
|> add_ingredient_quantity(ingredient[:cheese], "2 oz")
|> add_ingredient_quantity(ingredient[:salt], "1 teaspoon")
end
This worked great for ingredients that I had already defined in my keyword list, but the Access
Read more about Access
here. behaviour that powers Elixir’s []
syntax returns nil
if a key isn’t found in the accessed object.
I wanted a way to detect that I needed to define a new ingredient in a recipe, so that every ingredient gets its own page, like Grim Grains. My naive solution was to put a guard clause on add_ingredient_quantity
that checked if the ingredient was nil:
def add_ingredient_quantity(recipe, ingredient, quantity) when not is_nil(ingredient) do
...
end
This would certainly complain, because of a pattern match error. However, the Access
had already returned a nil
, so the missing ingredient’s atom wasn’t available for an error message.
This became evident when I tried to add waterYou would want to drink some water if you were eating that much salted cheese, after all. to my recipe:
def recipes do
ingredients = Ingredients.ingredients()
create_recipe("salted cheese")
|> add_ingredient_quantity(ingredient[:cheese], "2 oz")
|> add_ingredient_quantity(ingredient[:salt], "1 teaspoon")
|> add_ingredient_quantity(ingredient[:water], "1 quart")
end
This resulted in a pattern match error, but the error only knew that it was passed a nil
with no context about where that nil
came from.
Partial Application in Elixir
To solve this, I replaced the ingredient[:water]
callI first thought about overriding the Access
behaviour to return something other than nil
, but I decided against it. with a partial application.
In Elixir I can use an anonymous function to partially apply a named Elixir function. Here, I wanted to get a value out of a specific Keyword List, so I chose Keyword.fetch
. Keyword.fetch
takes two arguments: a keyword list and a key to getRead more about Keyword.fetch/2
here.. By partially applying Keyword.fetch
with ingredients
and wrapping it in a with
statement I can raise a helpful error:
def recipes do
ingredients = Ingredients.ingredients()
get_ingredient = fn key ->
with {:ok, value} <- Keyword.fetch(ingredients, key) do
value
else
:error -> raise "#{key} not found in ingredients"
end
end
create_recipe("salted cheese")
|> add_ingredient_quantity(get_ingredient.(:cheese), "2 oz")
|> add_ingredient_quantity(get_ingredient.(:salt), "1 teaspoon")
|> add_ingredient_quantity(get_ingredient.(:water), "1 quart")
end
Note that we’re using an anonymous function, so get_ingredient
needs to be applied with the .
operatorI initially tried to use the capture operator on Keyword.get/3
, passing raise
as the default as shown here. This successfully raised a helpful error on a missing key, but it eagerly raised on every key!.
Now, when I’m missing an ingredient I’ll get a helpful error message that reminds me to just add it.