Adopting Behaviors in Elixir: A Guide to Using the @impl Directive

A comprehensive guide covers everything from the basics to advanced techniques for using the @impl directive while adopting behaviours in Elixir. Learn about different approaches and best practices.
Stephen Njau
Stephen Njau
Adopting Behaviors in Elixir: A Guide to Using the @impl Directive

As a seasoned Elixir developer, you're no stranger to the power and versatility of behaviors. But even the most experienced coders can sometimes struggle with the @impl directive, used to adopt behaviors in Elixir. That's why I've put together this comprehensive guide, to help you get the most out of this essential tool.

We'll start by reviewing the basics of behaviors in Elixir, and how the @impl directive fits into the picture. Then we'll delve into the different ways you can use the @impl directive, including the pros and cons of each approach. Along the way, we'll use a feature flagging example to illustrate key concepts and best practices.

So let's get started!

First things first: behaviors in Elixir allow you to define a set of callbacks that must be implemented by any module that adopts the behavior. This is especially useful when working with third-party libraries, as it helps ensure a consistent interface for interacting with different services.

In our feature flagging example, we'll define a FeatureFlag behavior with a flag_enabled?/2 callback. Any module that adopts this behavior will be expected to implement this callback. Here is the code:

defmodule MyApp.FeatureFlag do
  @type user_id :: String.t()
  @type flag_name :: String.t()

  @doc """
  Checks whether a give feature flag is toggled on or of for a user.
  """

  @callback flag_enabled?(user_id, flag_name) :: {:ok, boolean} | {:error, String.t()}
end

Enter the @impl directive. This handy annotation acts as a compile-time check, ensuring that you're implementing the correct callback and helping you catch errors in your code. It also improves the maintainability of your code, making it clear to other developers (and to your future self!) which functions belong to which behaviors.

There are a few different ways you can use the @impl directive. One common approach is to annotate callback functions with @impl true or @impl false.

defmodule MyApp.LaunchDarkly do
  @behaviour FeatureFlag

  @impl true
  def flag_enabled?(_user_id, flag_name) do
    # Evaluate the flag using LaunchDarkly
  end
end
Using @impl true

This is the simplest method, and it's fine for modules that only adopt a single behavior. However, it can be confusing if you're working with multiple behaviors, as it's not clear which callback belongs to which behavior.

The @impl false directive is used to annotate a callback function as not being implemented in the current module. This can be useful in a few different situations:

  1. When a module is intended to be a "stub" or placeholder that will be implemented later, you can use @impl false to indicate that the callback is not yet implemented.
  2. If a module is adopting a behavior but you don't need to implement all of the callbacks, you can use @impl false for the ones you're not using. This can be helpful for maintaining a clear separation of responsibilities within your codebase.
  3. In some cases, you may want to override a callback implementation from a parent module. To do this, you can define the callback in the child module with @impl false, which will cause the parent implementation to be used instead.

It's worth noting that @impl false is not used as frequently as @impl true, but it can be a useful tool in certain situations. As with any programming construct, it's important to use it appropriately and with care to ensure the best results.

A more explicit approach is to use @impl <behavior>, where <behavior> is the name of the behavior being implemented. This is generally considered a best practice, as it's clear which behavior is being implemented and there's no need to scroll to the top of the module to figure it out.

defmodule MyApp.LaunchDarkly do
  @behaviour FeatureFlag

  @impl FeatureFlag
  def flag_enabled?(_user_id, flag_name) do
    # Evaluate the flag using LaunchDarkly
  end
end
using @impl with behaviour name

If you're working with a module that adopts multiple behaviors, it's especially important to use the more verbose @impl <behavior> syntax. This will help prevent any confusion or ambiguity.

defmodule MyApp.LaunchDarkly do
  @behaviour FeatureFlag
  use GenServer

  @impl FeatureFlag
  def flag_enabled?(_user_id, flag_name) do
    # Evaluate the flag using fun with flags
  end

  @impl Genserver
  def init(_stack) do
    ...
  end

  @impl Genserver
  def handle_call(:pop, _from, [_head | _tail]) do
    ...
  end

  @impl Genserver
  def handle_cast({:push, _element}, _state) do
    ...
  end
end
Explicit behaviour annotation for multiple behaviours in a single module


Finally, if you're using multiple clause callbacks with heavy pattern matching, it's usually best to annotate the first function.

defmodule MyApp.LaunchDarkly do
  @behaviour FeatureFlag
  use GenServer

  @impl FeatureFlag
  def flag_enabled?(nil, flag_name) do
    # Evaluate the flag using fun with flags
  end

  def flag_enabled?("admin-12", flag_name) do
    ...
  end

  def flag_enabled?("test-user", flag_name) do
    ...
  end

  def flag_enabled?(_user_id, flag_name) do
    ...
  end
end
Annotating only the first function clause

In summary, the @impl directive is a crucial tool for adopting behaviors in Elixir. By using it correctly, you can improve the maintainability, readability, and overall quality of your code. Whether you opt for the simplicity of @impl true or the clarity of @impl <behavior>, you'll be well on your way to mastering this powerful Elixir feature.

I hope you found this short guide useful. Thank you ❤️