typesmacroselixirnewtype

How to define Macro for a new Type?


Background

So, I am playing around with a concept named "NewType" and I am taking inspiration from languages like F# and Scala.

My objective, for learning purposes mostly, is to build a macro that makes creating this abstraction something that takes no more than a single line of code.

Intended usage

I would like to create a macro that allows me to do something like this:

defmodule User do
  require NewType # an absolutely original name for the macro :D

  deftype Name, String.t() # Usage of said macro. Here I am defining a new type called "Name"

  @enforce_keys [:name, :age]
  defstruct [:name, :age]
  @type t :: %__MODULE__{
          name: Name.t,
          age: integer()
        }

  @spec new(Name.t, integer) :: User.t
  def new(name, age), do: %User{name: name, age, age}  
end

And now, here is how I could create a User:

defmodule Test do
  alias User
  import User.Name

  @spec run :: User.t
  def run do
    name = Name("John")
    User.new(name, 25)
  end
end

How to implement this interface?

This interface might remind you a little of the Record interface. That's because I think its API has some good ideas I would like to explore.

So, as a starting point I tried reading the source code for Record, but I was not really able to pick it up and use it to create an implementation for my use case, mainly because I don't need/want to interface with Erlang records at all.

So, an implementation possibility would be to, under the hood, turn this into a tuple:

defmodule NewType do
  defmacro new(name, val) do
    quote do
      NewType.to_tuple(unquote(name), unquote(val))
    end
  end

  def to_tuple(name, val), do: {String.to_atom(name), val}
end

However, this is miles away from the interface I want to create ...

Questions

  1. Using Elixir macros, is it possible to create the API I am aiming for?
  2. How can I change my code to achieve something like Name("John")?

Solution

  • My Answer

    After reading more about macros in Elixir, talking to the community and reading about NewType, I have refined my ideas. While the exact implementation of my original idea is not possible, with some changes you can still get the core benefit of NewType.

    Changes to original idea

    Code

    With these changes in mind, this is the macro I came up with:

    defmodule NewType do
      defmacro deftype(name, type) do
        quote do
          defmodule unquote(name) do
            @opaque t :: {unquote(name), unquote(type)}
    
            @spec new(value :: unquote(type)) :: t
            def new(value), do: {unquote(name), value}
    
            @spec extract(new_type :: t) :: unquote(type)
            def extract({unquote(name), value}), do: value
          end
        end
      end
    
      @spec is_type?(data :: {atom, any}, new_type :: atom) :: boolean
      def is_type?({type, _data}, new_type) when type == new_type, do: true
      def is_type?(_data, _new_type), do: false
    end
    

    Which can be used like:

    type.ex:

    defmodule Type do
      import NewType
    
      deftype Name, String.t()
    end
    

    test.ex:

    defmodule Test do
      alias Type.Name
    
      @spec print(Name.t()) :: binary
      def print(name), do: Name.extract(name)
    
      def run do
        arg = 1
        name = Name.new(arg) # dialyzer detects error !
        {:ok, name}
      end
    end