typeselixirdialyzer

Dialyzer cannot recognize error in function using polymorphic types


Background

I am trying out polymorphic typing with dialyzer. As an example I am using the famous Option type (aka, Maybe Monad) that is now prevalent in many other languages these days.

defmodule Test do
  @type option(t) :: some(t) | nothing
  @type some(t) :: [{:some, t}]
  @type nothing :: []

  @spec validate_name(String.t()) :: option(String.t())
  def validate_name(name) do
    if String.length(name) > 0 do
      [{:some, name}]
    else
      nil
    end
  end
end

As you can see, the function validate_name should return (by spec definition) [{:some, String.t}] | [].

The issue here is that in reality, the function is returning [{:some, String.t}] | nil. nil is not the same as empty list [].

Problem

Given this issue, I would expect dialyzer to complain. However it gladly accepts this erroneous spec:

$ mix dialyzer
Compiling 1 file (.ex)
Finding suitable PLTs
Checking PLT...
[:compiler, :currying, :elixir, :gradient, :gradualizer, :kernel, :logger, :stdlib, :syntax_tools]
PLT is up to date!
No :ignore_warnings opt specified in mix.exs and default does not exist.

Starting Dialyzer
[
  check_plt: false,
  init_plt: '/home/user/Workplace/fl4m3/grokking_fp/_build/dev/dialyxir_erlang-24.2.1_elixir-1.13.2_deps-dev.plt',
  files: ['/home/user/Workplace/fl4m3/grokking_fp/_build/dev/lib/grokking_fp/ebin/Elixir.Book.beam',
   '/home/user/Workplace/fl4m3/grokking_fp/_build/dev/lib/grokking_fp/ebin/Elixir.DealingWithListsOfLists.beam',
   '/home/user/Workplace/fl4m3/grokking_fp/_build/dev/lib/grokking_fp/ebin/Elixir.Event.beam',
   '/home/user/Workplace/fl4m3/grokking_fp/_build/dev/lib/grokking_fp/ebin/Elixir.FlatMapsVSForComprehensions.beam',
   '/home/user/Workplace/fl4m3/grokking_fp/_build/dev/lib/grokking_fp/ebin/Elixir.ImmutableValues.beam',
   ...],
  warnings: [:unknown]
]
Total errors: 0, Skipped: 0, Unnecessary Skips: 0
done in 0m1.09s
done (passed successfully)

Furthermore, no matter what I put in the else branch, the result is always a "happy dialyzer".

Question

At this point, the only logical solution I can think of is that dialyzer is only concerned with the happy path. Meaning, it will ignore my else branch.

If dialzyer is only ever concerned with happy paths, then this would explain the issue (it is called success typing after all) but it also means it will totally miss a bunch of errors in my code.


Solution

  • also means it will totally miss a bunch of errors in my code.

    Your understanding is correct, dialyzer is not a statical type system and it will only be able to detect a subset of errors that will result in a confirmed type clash. This detailed article explains the design and trade-offs behind dialyzer.

    There are some warning flags though, like underspecs / overspecs / specdiffs, than can be enabled to detect more categories of errors: the list can be found here and dialyxir supports them as command line options.

    If running mix dialyzer --overspecs (or --specdiffs), you should get:

    your_file.ex:6:missing_range
    The type specification is missing types returned by function.
    
    Function:
    Test.validate_name/1
    
    Type specification return types:
    [{:some, binary()}]
    
    Missing from spec:
    nil
    

    Running mix dialyzer.explain missing_range:

    Function spec declares a list of types, but function returns value
    outside stated range.
    
    This error only appears with the :overspecs flag.
    

    Edit: Since OTP 25, Dialyzer will introduce two new flags: missing_return and extra_return, similar respectively to overspecs and underspecs, but with less false positives and more usable in practice.

    missing_return will catch the missing_range example above, but without returning a lot of noisy contract_subtype warnings that you might not really care about, like overspecs does.