Metaprogramming in Elixir
20 minutes readMetaprogramming in Elixir is one of those concepts that gets everyone excited, but difficult to find a reasonable use case for in our day-to-day code. It is most often used by library authors and might look a bit weird at first, but quite useful in practice. In this article, I’ll cover one case study where using macros was benefitial for us at work. The example is different from the real use in our codebase, but the idea is the same.
Working with Enums
There is generally no consensus when it comes to working with Enums
(constants not collections) in elixir. Maybe with Ecto, one could use the EctoEnum
module but for projects that do not persist data to a “DB”, bringing in an Ecto dependency is not ideal, assuming it works.
Fortunately, there’s a simple and intuitive library I always reach out for. It lets you define enums which you can then use as a regular elixir module.
defmodule MyApp.Enum do
use EnumType
defenum MatchStatus do
value NotStarted, 1
value Started, 2
default Ended, 3
end
end
All enums will be converted to regular modules and each value has a .value()
function that returns, well, the value.
defmodule MyApp.GameHandler do
alias MyApp.Enum.MatchStatus
...
def get_status(), do: MatchStatus.value()
end
The problem
What happens when we have multiple enums and these enums have to be combined in some way to produce a value defined in another enum? Let’s look at the following example:
def MyApp.Enum do
use EnumType
defenum Colors do
value RED, :red
value ORANGE, :orange
value YELLOW, :yellow
value GREEN, :green
value BLUE, :blue
value INDIGO, :indigo
value VIOLET, :violet
value BLACK, :black
value WHITE, :white
value MAROON, :maroon
value PURPLE, :purple
value TEAL, :teal
end
end
def MyApp.ColorPalette do
alias MyApp.Enum.Colors
def get_color(Colors.RED.value(), Colors.BLUE.value()), do: Colors.PURPLE.value()
def get_color(Colors.YELLOW.value(), Colors.BLUE.value()), do: Colors.GREEN.value()
def get_color(Colors.GREEN.value(), Colors.BLUE.value()), do: Colors.TEAL.value()
...
end
It is clear that as more and more colors need to be combined, the code becomes verbose and repetitive. It becomes more inconvinient when we have to unit test multiple combinations and while its not a show stopper, there might be a better way.
Solution
Ideally, we should be able to load multiple enums from the same namespace, assign them to a module attribute and prefix it with a sensible name. This should be done at compile time so that there is no runtime penalty and as long as it compiles, we’re certain it works. This is where metaprogramming comes into the picture. In Elixir, we can achieve this with the help of macros.
Modifying the previous example, we should have something like:
def MyApp.ColorPalette do
alias MyApp.Enum.Colors
import MyApp.EnumHelpers, only: [load_enums: 2]
load_enums(Colors, prefix: "color_", only: [RED, BLUE, PURPLE, YELLOW, GREEN, TEAL])
def get_color(@color_red, @color_blue), do: @color_purple
def get_color(@color_yellow, @color_blue), do: @color_green
def get_color(@color_green, @color_blue), do: @color_teal
...
end
The “magic” resides in the load_enums/2
macro that inspects the given enum module, extracts the values specified in the :only
option and assigns their values to a module attribute. We also have the option to prepend a string to each value so that we can use the macro with multiple enum types in the same module. If we wish to import all values in the enum, we can ignore the :only
option.
load_enums(Colors, prefix: "color_")
Similarly, we can ignore the :prefix
completely.
load_enums(Colors)
The macro is just a few line of code but is something we import in most of our projects at work. This makes the code a lot clearer and might be useful for other developers as well. I just might send a PR to the EnumType
library author :)
defmodule MyApp.EnumHelpers
defmacro load_enums(enum_module, opts \\ []) do
quote location: :keep do
whitelist =
Keyword.get(unquote(opts), :only, [])
|> Enum.map(fn key ->
Module.split(key) |> List.last() |> String.downcase()
end)
|> MapSet.new()
unquote(enum_module).enums()
|> Enum.each(fn enum ->
attr_name = Module.split(enum) |> List.last() |> String.downcase()
if MapSet.size(whitelist) == 0 || MapSet.member?(whitelist, attr_name) do
prefix = Keyword.get(unquote(opts), :prefix)
attr_name = if prefix, do: "#{prefix}_#{attr_name}", else: attr_name
Module.put_attribute(__MODULE__, String.to_atom(attr_name), enum.value())
end
end)
end
end
end
Metaprogramming is a powerful concept and while many in the industry advise against it (ab)use, it can significantly help reduce boilerplate code.