Use Macro with Defoverridable Function Fallbacks

An overhead view of a river that splits and then comes back together

You already know that Elixir is the right choice for your digital product development, now it’s time to partner with a team that knows how to make Elixir work for your unique goals. Book a free consult today to learn more.

I don’t use macros often, other than for using macros as a convenience for users when making libraries. If you are new to writing libraries or are digging deeper into metaprogramming, let me explain one case where @before_compile can be unexpectadly useful, that I just discovered recently.

If you use a lot of metaprogramming in Elixir, you are probably already familiar with @before_compile.

In some cases, it would be nice to be able to override a function and pattern-match on a special case, but let it fall back to the default for the rest of the cases. This is not something you would want to do in most circumstances and could actually be surprising to users. One case that may be a good candidate for this, is if you have a code path that the user has a very specific case they want to override, but you want the common case to always apply otherwise.

Now that we’ve prefaced with why we probably do not want to do this, here is how we can do it.

Assume we have this code:

defmodule Thing.Handler do
  require Logger

  @callback handle_thing(thing :: term()) :: term()

  defmacro __using__(_opts) do
    quote do
      @behaviour Thing.Handler

      @impl Thing.Handler
      def handle_thing(thing) do
        Thing.Handler.default_handle_thing(thing)
      end

      defoverridable handle_thing: 1
    end
  end

  def default_handle_thing(thing) do
    Logger.debug(inspect(thing))
  end
end

Now anyone can use this behavior and the default implementation like so:

defmodule Implementation do
  use Thing.Handler
end

Next, we want to override the function for our special case:

defmodule Implementation do
  use Thing.Handler

  @impl Thing.Handler
  def handle_thing(1) do
    # Handling a specific case ourselves.
  end
end

We are now handling the specific case we care about. However, if it doesn’t match that case, we will get a FunctionClauseError for no matching function.

The typical way to deal with this, if you want to maintain the default implementation would be to use super.

defmodule Implementation do
  use Thing.Handler

  @impl Thing.Handler
  def handle_thing(1) do
    # Handling a specific case ourselves.
  end

  # Catch-all fallback to default implementation.
  def handle_thing(thing), do: super(thing)
end

Finally, we will set this up so that if the user of the library forgets to handle the general case to call super, that it always falls back to the default implementation.

This is where @before_compile comes in. It is described as

A hook that will be invoked before the module is compiled. This is often used to change how the current module is being compiled.

To me, that sounds like “blah blah… words,” so let’s see it in action. We’ll update the original Thing.Handler module and add @before_compile, which looks like:

defmodule Thing.Handler do
  require Logger

  @callback handle_thing(thing :: term()) :: term()

  defmacro __using__(_opts) do
    quote do
      @behaviour Thing.Handler

      @impl Thing.Handler
      def handle_thing(thing) do
        Thing.Handler.default_handle_thing(thing)
      end

      @before_compile {Thing.Handler, :add_handle_thing_fallback}

      defoverridable handle_thing: 1
    end
  end

  defmacro add_handle_thing_fallback(_env) do
    quote do
      def handle_thing(thing) do
        Thing.Handler.default_handle_thing(thing)
      end
    end
  end

  def default_handle_thing(thing) do
    Logger.debug(inspect(thing))
  end
end

Now we can override the function for specific cases and the rest will fall through to the default_handle_thing/1 function!

Newsletter

Stay in the Know

Get the latest news and insights on Elixir, Phoenix, machine learning, product strategy, and more—delivered straight to your inbox.

Narwin holding a press release sheet while opening the DockYard brand kit box