Montjuïc

There are two types of erlang and elixir developers: those who write specs for Dialyzer and those who don’t yet. At first glance, it seems to be a waste of time, especially for those who come from languages with lax typing. However, specs have helped me catch several bugs even before the CI stage, and-sooner or later-any developer realizes that they are indeed a goodness. Not only as a tool to guide semi-strict typing, but also as a great help in documenting the code.

But, as is always happens in our cruel world, there are drawbacks. Essentially, the @spec directives duplicate the function declaration code. Below I am going to demonstrate how twenty lines of code can help to combine a specification and a function declaration into a nifty

defs is_forty_two(n: integer) :: boolean do
  n == 42
end

They say, there is nothing in Elixir, but macros. Even Kernel.defmacro/2 — is a macro itself. Therefore, all we need is to define our own macro, which will create from the construction above both spec and a function declaration.

Vamonos.

Step 1. Understanding the Task

Let’s start with unveiling the AST that our not-yet-built macro would receive as a parameter.

defmodule CustomSpec do
  defmacro defs(args, do: block) do
    IO.inspect(args)
    :ok
  end
end

defmodule CustomSpec.Test do
  import CustomSpec

  defs is_forty_two(n: integer) :: boolean do
    n == 42
  end
end

Wow, wow, not so fast. We immediately ran into formatter’s rebellion. She spat a ton of parentheses here and there and has the code reformatted in a way that makes everybody having soul cry. Let’s wean her off it. To do that we need to change the configuration file .formatter.exs in the following way

[
  inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"],
  export: [locals_without_parens: [defs: 2]]
]

Let’s go back to our daily duties and see what defs/2 gets in there. It should be noted that IO.inspect/2 will be executed at the compilation stage (if you do not understand why, you probably should not tweak macros yet, but read a brilliant book Metaprogramming Elixir by Chris McCord.) Also, to prevent the compiler from swearing, we return: ok (macros must return the correct AST). So:

{:"::", [line: 7],
 [
   {:is_forty_two, [line: 7], [[n: {:integer, [line: 7], nil}]]},
   {:boolean, [line: 7], nil}
 ]}

Yeah. The parser believes that the imperator here is ::, gluing the function definition and the return type. The function definition also contains a list of parameters as Keyword, parameter name → type.

Step 2. Fail Fast

Since we have decided to support only that syntax for starters, we would need to rewrite the definition of the macro defs to raise immediately if, for example, the return type is not specified.

defmacro defs({:"::", _, [{fun, _, [args_spec]}, {ret_spec, _, nil}]}, do: block) do

Well, it i-i-i-i-i-is an Implementation Time.

Step 3. Generation of Both Spec and Function Declaration

defmodule CustomSpec do
  defmacro defs({:"::", _, [{fun, _, [args_spec]}, {ret_spec, _, nil}]}, do: block) do
    # function declaration arguments
    args = for {arg, _spec} <- args_spec, do: Macro.var(arg, nil)
    # spec declaration arguments
    args_spec = for {_arg, spec} <- args_spec, do: Macro.var(spec, nil)

    quote do
      @spec unquote(fun)(unquote_splicing(args_spec)) :: unquote(ret_spec)
      def unquote(fun)(unquote_splicing(args)) do
        unquote(block)
      end
    end
  end
end

The code is so evident, that I hesitate to add anything to it.

Let’s see how would CustomSpec.Test.is_forty_two(42) behave

iex> CustomSpec.Test.is_forty_two 42
#⇒ true
iex> CustomSpec.Test.is_forty_two 43
#⇒ false

Well, it works. I would not dump the BEAM chunk here, bear with me. You are to believe that spec works as well.

Step 4. Is That It?

Of course not. In the wild, one would have to properly handle invalid calls, to implement header definitions for functions with several different default parameters, to collect the spec more accurately (with variable names included,) to make sure that all argument names are different, and much more. But as the proof of concept it’s already good enough.

Also, one still may surprise colleagues with something like

defmodule CustomSpec do
  defmacro __using__(_) do
    import Kernel, except: [def: 2]
    import CustomSpec

    defmacro def(args, do: block) do
      defs(args, do: block)
    end
  end

  ...
end

(Also defs/2 i s to be modified to generate Kernel.def instead of def,) but take it for granted: you do not want to suprise mates in such a weird way. Even on April 1st.

Happy macroing!