The macro mechanism in Elixir is not only an interesting metaprogramming feature - in fact, it is at the language's very core. And the more awesome fact is that, using macros, you can override the algorithm of defining functions itself!

Table of contents

    This TIL is a build-up for Michal Buszkiewicz's ElixirConf EU 2021 talk on debugging in Elixir - see you in Warsaw on Sep 10, 2021!

    Elixir is a language built upon macros, that are used to transform code into different code. Some of the language's keywords such as case , cond , etc. are actually implemented at the compiler level and are shimmed with the Kernel.SpecialForms module, whereas others such as raise , if , unless etc. are implemented as macros that do their job interacting with Erlang-provided modules as well as those from Kernel.SpecialForms .

    Hell, even def is defined as a macro and to make things even weirder, so is defmacro itself (explained simply enough here).

    One thing that you have to keep in mind before you go further is that Kernel and Kernel.SpecialForms is imported automatically into each of your custom modules. You can, however, opt to use the except option to skip certain macros:

    defmodule NoIf do
      import Kernel, except: [if: 2]
    
      def foo() do
        # trying to use if/2 will result in an error
      end
    end

    That's just an example. How can we use it for more practical purposes? We can, for instance, override def to tamper with every function that we implement inside the module - for whatever purposes (logging function calls, other debugging purposes, etc.).

    defmodule CustomDef do
      defmacro def(call, expr \\ nil) do
        IO.inspect([call: call, expr: expr], label: "Defining")
    
        quote do
          Kernel.def(unquote(call), unquote(expr))
        end
      end
    end
    
    defmodule NoDef do
      import Kernel, except: [def: 2, def: 1, defp: 2, defp: 1]
      import CustomDef
    
      def foo(arg) do
        {:ok, :foo, arg * 2}
      end
    end
    
    IO.inspect(NoDef.foo(2), label: "Inside NoDef.foo/0")
    IO.inspect(NoDef.foo(3), label: "Inside NoDef.foo/0")
    

    The output is:

    Defining: [
      call: {:foo, [line: 15], [{:arg, [line: 15], nil}]},
      expr: [
        do: {:{}, [line: 16],
         [:ok, :foo, {:*, [line: 16], [{:arg, [line: 16], nil}, 2]}]}
      ]
    ]
    Inside NoDef.foo/0: {:ok, :foo, 4}
    Inside NoDef.foo/0: {:ok, :foo, 6}

    For demonstration purposes, I wanted to show that we were able to put an output message at the stage of defining the function, and - as you can see that expr contains the function's AST - you can alter it so that, for instance, every function you create in your module contains a logger call at the beginning.

    For easier usage in any of your modules, you could create a __using__ macro so you can plug it in with the use keyword.

    How to check if a set contains exact values with Jest in JS?

    Michał Buszkiewicz, Elixir Developer
    Michał Buszkiewicz Curiosum Founder & CTO

    Read more
    on #curiosum blog