How to test dialyzer?

Background

Following the advice of a book I have been recently reading Grokking Functional Programming, I have come across the concept of Types.

The book defends that native types are something to be avoided, and that we should use custom types. Long story short, the authors defend this eliminates a whole class of bugs in on itself.

Code

So to test this idea, with the help of wonderful people from this community, I came up with a macro I call NewType:

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}, type), do: true
  def is_type?(_data, _new_type), do: false
end

How to use it?

First you define a type. Let’s say, name.ex:

defmodule Type do
  import NewType

  deftype(Name, String.t())
end

And then you use it!

  def run_3 do
    print(Name.new("dow"))
  end

Problem

I have made it so my code works with dialyzer perfectly.

defmodule Test do
  alias Type.Name

  @spec print(Name.t()) :: binary
  def print(name), do: Name.extract(name)

  def run_1 do
    # dialyzer complains !
    Name.new(1)
  end

  def run_2 do
    # dialyzer complains !
    print("john")
  end

  @spec run_3 :: binary
  def run_3 do
    print(Name.new("dow"))
  end
end

However, these are more code samples than tests. These “tests” do not fail. I know my code behaves as expected because I see dialyzer complaining in my IDE, but if/when I make changes to this, I will want to run it automatically and not open my IDE and look at it (or run mix dialyzer and looks at the output ).

Question

Is there a way to have a test that ensures dialyzer complains and have it run on mix test ?

1 Like

If remember correctly the simplest way is to check the shell status code using for example System.cmd/2.

case System.cmd("command", ["--list", "--of", "--arguments"]) do
  {_output, 0} -> :ok
  {_output, _status} -> # failed
end

Unfortunately the task itself uses Mix.shell/1 as last call which according to docs always returns :ok, so Mix.Task.run/1 would not work here. Maybe you can find something more in dialyxir documentation …

1 Like

Setup an alias in mix.exs:

def aliases do
  [
    test: ["dialyzer", "test"]
  ]
end
1 Like

Perhaps the better question here would then be, how do you properly test a macro ?
Maybe this would make more sense, given what I am basically doing is an e2e test of a macro with dialzyer.
However, if my macro always produces the code it should, then I know for a fact Dialyzer will complain (assuming dialyzer will behave as expected).

Thank you for the comment. It helped me figure out another direction.

How about this code:

defmodule NewType do
  defmacro deftype(module_alias, {param_atom, _param_meta, nil} = param, spec, guards) do
    guard_name = :"is_#{param_atom}"
    module = Module.concat(__CALLER__.module, Macro.expand(module_alias, __CALLER__))
    tuple_guards = ast_replace(guards, param, quote(do: elem(tuple, 1)))

    quote do
      defmodule unquote(module) do
        @opaque t :: {__MODULE__, unquote(spec)}

        @spec extract(new_type :: t) :: unquote(spec)
        def extract({__MODULE__, unquote(param)}) when unquote(guards), do: unquote(param)

        @spec new(unquote(param) :: unquote(spec)) :: t
        def new(unquote(param)) when unquote(guards), do: {__MODULE__, unquote(param)}

        defguard unquote(guard_name)(tuple)
                 when is_tuple(tuple) and tuple_size(tuple) == 2 and elem(tuple, 0) == __MODULE__ and
                        unquote(tuple_guards)
      end
    end
  end

  defp ast_replace(param, param, value), do: value

  defp ast_replace(list, param, value) when is_list(list) do
    Enum.map(list, &ast_replace(&1, param, value))
  end

  defp ast_replace({call, meta, args}, param, value) do
    {call, meta, ast_replace(args, param, value)}
  end

  defp ast_replace(ast, _param, _value), do: ast
end

with following usage:

defmodule Type do
  import NewType, only: [deftype: 4]

  deftype(Name, name, String.t(), is_binary(name))
end

and this test instead

defmodule Test do
  use ExUnit.Case

  alias Type.Name

  test "run_1" do
    assert_raise(FunctionClauseError, fn -> Name.new(1) end)
  end

  test "run_2" do
    assert_raise(FunctionClauseError, fn -> print("john") end)
  end

  test "run_3" do
    name = "dow"
    assert name == name |> Name.new() |> print()
  end

  @spec print(Name.t()) :: binary
  defp print(name), do: Name.extract(name)
end

dialyzer does not check files in test directory, so we can use any needed assertions.

1 Like