Strange Dialyzer issue resolved by random IO.inspect

I have this bit of code that’s currently erroring at the _ match:

projects
|> Projects.create_many_notify(socket.assigns.study.id)
|> case do
  {:error, changeset} ->
    changeset.errors

  _ ->
    nil
end

The function returns a type of changeset result ({:ok, [struct]} | {:error, changeset}). The full error message is:

The variable _ can never match since previous clauses completely covered the type 
          {'error',
           #{'__struct__' := 'Elixir.Ecto.Changeset',
             'action' := atom(),
             'changes' := #{atom() => _},
             'constraints' :=
                 [#{'constraint' := binary(),
                    'error_message' := binary(),
                    'error_type' := atom(),
                    'field' := atom(),
                    'match' := 'exact' | 'prefix' | 'suffix',
                    'type' :=
                        'check' | 'exclusion' | 'foreign_key' | 'unique'}],
             'data' := 'nil' | map(),
             'empty_values' := _,
             'errors' := [{atom(), {binary(), [{atom(), _}]}}],
             'filters' := #{atom() => _},
             'params' := 'nil' | #{binary() => _},
             'prepare' := [fun((_) -> any())],
             'repo' := atom(),
             'repo_opts' := [{atom(), _}],
             'required' := [atom()],
             'types' :=
                 'nil' |
                 #{atom() =>
                       atom() |
                       {'array', _} |
                       {'in', _} |
                       {'map', _} |
                       {'maybe', _} |
                       {'param', 'any_datetime'} |
                       {'parameterized', atom(), _}},
             'valid?' := boolean(),
             'validations' := [{atom(), _}]}}

But now here’s the weird thing. If I add |> IO.inspect() the error disappears!?:

projects
|> Projects.create_many_notify(socket.assigns.study.id)
|> IO.inspect()
|> case do
  {:error, changeset} ->
    changeset.errors

  _ ->
    nil
end

I can’t for the life of me figure out why…

I think it’s because of the flow analysis of the type that arrives at the case statement. IO.inspect/2 returns a var type, which basically means it can be anything (no restrictions). It would be interesting to find out how dialyzer knows that create_many_notify/2 will always fail and return an error tuple.

1 Like

Problem is it doesn’t always fail - It works fine :frowning:

This is the end of that create_many_notify/2 function:

# other multi stuff up here
|> Repo.transaction()
|> case do
  {:error, _, changeset, _} ->
    {:error, changeset}

  {:ok, projects} ->
    {:ok, Map.values(projects)}
end

Oh, just noticed: the return type is {:ok, struct} | {:error, changeset} and you’re returning a list of values {:ok, Map.values(projects)} in an ok-tuple, not a struct. I suppose that’s why dialyzer somehow thinks that only an error tuple will ever be returned. Just a wild guess. In this case dialyzer should definitely rather report that the success typing doesn’t match the return expression. Maybe it’s a bug.

That’s my fault in the original message. Yeah it’s a list of structs and the function is typespec’d as such. Either way the case statement don’t actually match on it so that shouldn’t be the issue.

So the return type is actually {:ok, [struct]} | {:error, changeset}?

Correct:

@spec create_many_notify(attrs_list :: [map()], study_id :: uuid()) :: ecto_result([Project.t()])

And those are just some aliases:

@type result(a, b) :: {:ok, a} | {:error, b}
@type ecto_result(a) :: result(a, Ecto.Changeset.t())
@type uuid() :: Ecto.UUID.t()
1 Like

I’m able to reproduce the error with the following reduced example:
(Notice that the incorrect return type ecto_result(Project.t()) is being used)

defmodule Projects do
  @type result(a, b) :: {:ok, a} | {:error, b}
  @type ecto_result(a) :: result(a, Ecto.Changeset.t())
  @type uuid() :: Ecto.UUID.t()

  defmodule Project do
    defstruct [:title]
    @type t :: %__MODULE__{title: binary}
  end

  @spec transaction(fun_or_multi :: (... -> any) | Ecto.Multi.t(), opts :: Keyword.t()) ::
    {:ok, any}
    | {:error, any}
    | {:error, Ecto.Multi.name(), any, %{required(Ecto.Multi.name()) => any}}
  def transaction(fun_or_multi, opts \\ []) do
    Ecto.Repo.Transaction.transaction(__MODULE__, __MODULE__, fun_or_multi, opts)
  end

  # @spec create_many_notify(attrs_list :: [map], study_id :: uuid) :: ecto_result([Project.t()])
  @spec create_many_notify(attrs_list :: [map], study_id :: uuid) :: ecto_result(Project.t())
  def create_many_notify(_list, _study_id) do
    Ecto.Multi.new()
    |> transaction()
    |> case do
      {:error, _, changeset, _} -> {:error, changeset}
      {:ok, projects} -> {:ok, Map.values(projects)}
    end
  end

  def test do
    socket = %{assigns: %{study: %{id: "e6c48b5a-1a45-46a6-8f7f-bc18b5a4ccf4"}}}

    [%{}]
    |> create_many_notify(socket.assigns.study.id)
    # |> IO.inspect()
    |> case do
      {:error, changeset} -> changeset.errors
      _ -> nil
    end
  end
end

I get almost the same warning as the one in your original post, but just the wording of the warning message is slightly different. That means our versions of dialyzer/OTP/Elixir are different. So, if I use the correct return type ecto_result([Project.t()]), then the dialyzer warning disappears. I’d say try it out with Elixir 1.12.1+ and OTP 24 again?