Can Enum.each mislead Dialyzer into accepting incorrect typespecs?

I’ve encountered some unusual behavior from Dialyzer while validating values from Application.get_env and reduced it down to the following code with a deliberately incorrect typespec (should be list(module()) on the return):

  @spec actually_a_list_of_modules(list(term())) :: list(DateTime.t())
  def actually_a_list_of_modules(list) when is_list(list) do
    validate_modules(list)
    list
  end

  defp validate_modules(modules), do: Enum.each(modules, &ensure_valid_module/1)

  defp ensure_valid_module(a) when is_atom(a), do: :ok
  defp ensure_valid_module(a), do: raise("Not a module: #{inspect(a)}")

Dialyzer accepts this without complaint, and I’m not sure why. When I change the validate_modules function to destructure the list manually, Dialyzer complains as expected:

  # This implementation fixes the problem, and Dialyzer correctly shows an error.
  defp validate_modules([]), do: nil

  defp validate_modules([a | b]) do
    ensure_valid_module(a)
    validate_modules(b)
  end

I also tried implementing this with a comprehension to see if something specific to Enum.each was affecting the type checking, but it behaves in the same way as the first example.

  # This is incorrectly accepted, the same as the Enum.each version.
  defp validate_modules(modules), do: for(m <- modules, do: ensure_valid_module(m))

I understand this could just be a limitation of Dialyzer, and I’m happy to refactor my code accordingly. I’d love to know what’s causing this behaviour, though. Why’s Dialyzer getting confused?

(Reproduced on Elixir 1.16.3 + Erlang/OTP 26.2 and also Elixir 1.14.3 + Erlang/OTP 25.3)

Dialyzer is unfortunately clueless about protocols in general and Enumerable in particular.

It also doesn’t have the notion of generics: Function.identity/1 will basically do type-laundering and its output will just end up being a term (instead of preserving the type of the input).

I understand this could just be a limitation of Dialyzer

Indeed, this is a limitation of Dialyzer’s success typing, this great article can help set up the right expectations and avoid expecting it to behave like a static type system.

3 Likes

Thanks, that’s useful. Am I correct in thinking that for comprehensions are desugared into Enum calls, and that’s why it has the same behaviour?

Indeed you are correct!

1 Like