Consider the implementation of the scaffold module that injects the struct with some custom fields into the module calling use Scaffold
.
Upon call to use Scaffold, fields: foo: [custom_type()], ...
we want to implement the proper type in Consumer
module (common_field
below comes from Scaffold
.)
@type t :: %Consumer{
common_field: [atom()],
foo: [custom_type()],
...
}
That would be great if we could both precisely specify a type in Consumer
for the future reference and generate the appropriate documentation for users of our new module.
The more comprehensive example would look like.
defmodule Scaffold do
defmacro __using__(opts) do
quote do
@fields [
:version
# magic
]
@type t :: %__MODULE__{
version: atom()
# magic
}
defstruct @fields
end
end
end
defmodule Consumer do
use Scaffold, fields: [foo: integer(), bar: binary()]
end
resulting after compilation in
defmodule Consumer do
@type t :: %Consumer{
version: atom(),
foo: integer(),
bar: binary()
}
use Scaffold, fields: [foo: integer(), bar: binary()]
end
Looks pretty easy, doesn’t it?
Naïve Approach
Let’s start with inspecting what do we receive in Scaffold.__using__/1
.
defmacro __using__(opts) do
IO.inspect(opts)
end
#⇒ [fields: [foo: {:integer, [line: 2], []},
# bar: {:binary, [line: 2], []}]]
So far, so good. Maybe we are almost there?
quote do
custom_types = unquote(opts[:fields])
...
end
#⇒ == Compilation error in file lib/consumer.ex ==
# ** (CompileError) lib/consumer.ex:2: undefined function integer/0
Bang! Types are special, one cannot simply unquote it anywhere. Maybe unquoting inplace would work?
@type t :: %__MODULE__{
unquote_splicing([{:version, atom()} | opts[:fields]])
}
#⇒ == Compilation error in file lib/scaffold.ex ==
# ** (CompileError) lib/scaffold.ex:11: undefined function atom/0
No dice. Types are hard, ask anyone who is doing Haskell for living (and Haskell has a very poor type system yet, dependent types are way better, but two ways harder.)
OK, looks like we need to build the whole clause as an AST and inject it at once, so that compiler would meet the proper declaration from scratch.
Constructing Type AST
I would skip two hours of my tossing, torments, trial, and errors. Everyone knows that I write code mostly at random, expecting that all of a sudden another permutation would compile and hence work. The issue here is contexts. We should shove the received fields definitions down to the macro declaring type without unquoting them because once unquoted, the type like binary()
would be immediately considered a function and whack-a-mole’d called by a compiler, resulting in CompileError
.
Also, we cannot use regular functions inside quote do
because the whole content of the block passed to quote
would be quoted.
quote do
Enum.map([:foo, :bar], & &1)
end
#⇒ {
# {:., [], [{:__aliases__, [alias: false], [:Enum]}, :map]}, [],
# [[:foo, :bar], {:&, [], [{:&, [], [1]}]}]}
You can see all these Enum
, :map
etc there as is. In other words, we should constuct the whole AST of the type outside of the quote and then unquote it inside. Let’s try it.
Less Naïve Attempt
We need to inject AST as AST, without unquoting it. Fine. Sounds as a stalemate?—Well, not really.
defmacro __using__(opts) do
fields = opts[:fields]
keys = Keyword.keys(fields)
type = ???
quote location: :keep do
@type t :: unquote(type)
defstruct unquote(keys)
end
end
All we need to do now, is to produce the proper AST, everything else is OK. Well, let Elixir do that for us!
iex|1 ▶ quote do
...|1 ▶ %Foo{version: atom(), foo: binary()}
...|1 ▶ end
#⇒ {:%, [],
# [
# {:__aliases__, [alias: false], [:Foo]},
# {:%{}, [], [version: {:atom, [], []}, foo: {:binary, [], []}]}
# ]}
Maybe something simpler?
iex|2 ▶ quote do
...|2 ▶ %{__struct__: Foo, version: atom(), foo: binary()}
...|2 ▶ end
#⇒ {:%{}, [],
# [
# __struct__: {:__aliases__, [alias: false], [:Foo]},
# version: {:atom, [], []},
# foo: {:binary, [], []}
# ]}
Looks promising, isn’t it? We are ready to get to the resulting code.
Working Solution
defmacro __using__(opts) do
fields = opts[:fields]
keys = Keyword.keys(fields)
type =
{:%{}, [],
[
{:__struct__, {:__MODULE__, [], Elixir}},
{:version, {:atom, [], []}}
| fields
]}
quote location: :keep do
@type t :: unquote(type)
defstruct unquote(keys)
end
end
or, if you don’t need to propagate types from Scaffold
itself, even simpler, as suggested by Qqwy here (it wouldn’t work with propageted types, version: atom()
outside of the quote raises.)
defmacro __using__(opts) do
fields = opts[:fields]
keys = Keyword.keys(fields)
fields_with_struct_name = [__struct__: __CALLER__.module] ++ fields
quote location: :keep do
@type t :: %{unquote_splicing(fields_with_struct)}
defstruct unquote(keys)
end
end
Here is the result of executing mix docs
:
Appendix. Trick With Quoted Fragment
But what if we already have a complicated quoted block inside our __using__/1
macro that binds the values quoted? Rewrite a ton of code to unquote everything everywhere? That’s not even always possible, if we want to have an access to anything declared inside the target module. Lucky us, we have a simpler way to achieve that.
NB for the sake of brevity, I would show the success path to declare all custom fields all having
atom()
type, but it would be easily extendable to accept any types from the input parameters, likeGenServer.on_start()
etc, this part would be left as a homework.
Now we are to generate the type inside quote do
block, because we cannot pass atom()
bound quoted (it would raise CompileError
as shown above.) So we’d resort to somewhat like
keys = Keyword.keys(fields)
type =
{:%{}, [],
[
{:__struct__, {:__MODULE__, [], Elixir}},
{:version, {:atom, [], []}}
| Enum.zip(keys, Stream.cycle([{:atom, [], []}]))
]}
That’s all good, but how would we inject this AST into @type
declaration? The very handy Elixir feature named Quoted Fragment comes to the rescue. It was designed to allow compile-time generated code like
defmodule Squares do
Enum.each(1..42, fn i ->
def unquote(:"squared_#{i}")(),
do: unquote(i) * unquote(i)
end)
end
Squares.squared_5
#⇒ 25
Quoted Fragments automagically recognized by Elixir inside quotes having a quoted bindings. Easy-peasy.
defmacro __using__(opts) do
keys = Keyword.keys(opts[:fields])
quote location: :keep, bind_quoted: [keys: keys] do
type =
{:%{}, [],
[
{:__struct__, {:__MODULE__, [], Elixir}},
{:version, {:atom, [], []}}
| Enum.zip(keys, Stream.cycle([{:atom, [], []}]))
]}
# ⇓⇓⇓⇓⇓⇓⇓⇓⇓⇓⇓⇓⇓
@type t :: unquote(type)
defstruct keys
end
end
This alone unquote/1
is allowed inside quote/2
receiving bind_quote:
keys in the parameters keyword.
Happy injecting!