How to create a type attribute in a macro?

How can I create a macro that defines a type using atom that represents the type name? In the macro __type__ below, for the visibility argument :private, I’d like the macro to emit:
@typep t() :: { ... }

def visibility(:public),  do: :type
def visibility(:private), do: :typep
def visibility(:opaque),  do: :opaque

defmacro __type__(types, visibility) do
  attr = visibility(visibility)
  quote bind_quoted: [attr: attr, types: types] do
    Module.put_attribute(__MODULE__, unquote(attr), t() :: {unquote_splicing(types)}
  end
end

Compilation fails with:

== Compilation error in file test/type_test.exs ==
** (CompileError) test/type_test.exs:49: misplaced operator ::/2

The :: operator is typically used in bitstrings to specify types and sizes of segments:

    <<size::32-integer, letter::utf8, rest::binary>>

It is also used in typespecs, such as @type and @spec, to describe inputs and outputs
    ... expanding macro: Mod.__type__/2
1 Like

You want to emit the actual type notation, something like this:

@unquote(attr)(t() :: {unquote_splicing(types)})
2 Likes
defmacro __type__(types, visibility) do
  attr = Mod.visibility(visibility)
  quote bind_quoted: [attr: attr, types: types] do
    @unquote(attr)(t() :: {unquote_splicing(types)})
  end
end

For this invocation I get:

== Compilation error in file test/type_test.exs ==
** (CompileError) test/type_test.exs:79: unquote called outside quote
    (elixir 1.13.2) expanding macro: Kernel.@/1
1 Like

You can’t mix bind_quoted with unquoutes.

2 Likes

How would you rewrite the above to keep the compiler happy?

1 Like

Here you go:

defmodule Example do
  defmacro sample(ast, visibility) do
    # sample visibility handling
    attr =
      case visibility do
        :public -> :type
        :private -> :typep
      end

    # this optional line allows you to use sigils:
    # Example.sample(~w[a b c]a, :private)
    list = Macro.expand(ast, __CALLER__)

    # if unuoqte needs to be used which is the case here
    # then quote cannot have bind_quoted option set
    # as this option sets unquote option to false
    # making unquote/1 calls not work
    quote do
      # @ is a macro which means we need to use unquote here
      # to pass a raw data instead of ast not recognized by said macro
      @unquote(attr)(t() :: {unquote_splicing(list)})
    end
  end
end

Helpful resources:

  1. @/1 macro
  2. quote/2 special form
2 Likes

How would you rewrite it if the first argument to your sample/2 macro is actually the content of a dynamically built attribute? E.g.

def prep_sample() do
  quote do
    Module.register_attribute(__MODULE__, :types, accumulate: true)
  end
  Module.put_attribute(mod, :types, {:a, String.t()})
  Module.put_attribute(mod, :types, {:b, atom()})
  Module.put_attribute(mod, :types, {:c, integer()})
end

And I essentially want to make the type containing the types taken from the types attribute in the “pseudo” code below:

defmacro sample(types, visibility) do
  attr =
    case visibility do
      :public -> :type
      :private -> :typep
    end
  list = for {_,type} <- types, do: type
  quote do
    @unquote(attr)(t() :: {unquote_splicing(list)})
  end
end

def define_type(visibility) do
  prep_sample()
  sample(Module.get_attribute(__MODULE__, :types), visibility)
end
1 Like

Your code would not even compile. Also you can put module attributes only when mod is does not yet finished compiling. quote/1 inside a function should be returned (as last expression) when said function is called by macro. Otherwise in function body you would have a quoted expression which would not be evaluated.

Maybe you look for something like this?

defmodule Example do
  defmacro __using__(_opts \\ []) do
    quote do
      import Example, only: [type: 2, using: 1, using: 2]
      Module.register_attribute(__MODULE__, :_types, accumulate: true)
    end
  end

  defmacro type(name, ast) do
    quote bind_quoted: [ast: Macro.escape(ast), name: name] do
      @_types {name, ast}
    end
  end

  defmacro using(opts \\ [visibility: :public], do: block) do
    attr = visibility(opts[:visibility])

    quote bind_quoted: [attr: attr, block: block] do
      _ = block
      list = @_types |> Enum.map(&elem(&1, 1)) |> Enum.reverse()

      case attr do
        :opaque -> @opaque t() :: {unquote_splicing(list)}
        :type -> @type t() :: {unquote_splicing(list)}
        :typep -> @typep t() :: {unquote_splicing(list)}
      end
    end
  end

  defp visibility(:opaque), do: :opaque
  defp visibility(:private), do: :typep
  defp visibility(:public), do: :type
end

defmodule Test do
  use Example

  using visibility: :private do
    type :a, String.t()
    type :b, atom()
    type :c, integer()
  end
end
1 Like

Thanks Eiji! What prompted this question was my modification of the typed_struct library that adds support of typedrecord, e.g.:

defmodule Person do
  use TypedStruct

  typedrecord :person do
    @typedoc "A person"

    field :name, String.t(),
    field :age,  non_neg_integer(), default: 0
  end
end

The code above will be expanded to:

defmodule Person do
  use Record
  Record.defrecord(:person, name: nil, age: 0)
  @type person :: {:person, String.t, non_neg_integer}
end

There were two places in my implementation that I wanted to improve: this and this to avoid repetition.

1 Like