Dialyzer analyzes code that runs on the Erlang VM, looking for potential errors. To use it with Elixir, we have to compile our source into .beam files and make sure that the debug_info compiler option is set (which it is when running mix in the default, development mode). Let’s see how to do that by creating a trivial project with two source files.
| $ mix new simple |
| ... |
| $ cd simple |
Inside the project, let’s create a simple function. Being lazy, I haven’t implemented the body yet.
| defmodule Simple do |
| @type atom_list :: list(atom) |
| @spec count_atoms(atom_list) :: non_neg_integer |
| def count_atoms(list) do |
| # ... |
| end |
| end |
Let’s run dialyzer on our code. To make life simple, we’ll use the dialyxir library to add a dialyzer task to mix:
| defp deps do |
| [ |
| { :dialyxir, "~> 0.5", only: [:dev], runtime: false } |
| ] |
| end |
Fetch the library and build our project:
| $ mix deps.get |
| $ mix compile |
Now we’re ready to analyze our code. However, the first time we do this, dialyzer needs to construct a massive data structure containing all the types and APIs in both Erlang and Elixir. This lets it check not just our code, but also that our code is interacting correctly with the rest of the world. Building this data structure is slow: expect it to take 10 to 20 minutes! But once done, it won’t be repeated.
| mix dialyzer |
| Compiling 2 files (.ex) |
| warning: variable "list" is unused |
| lib/simple.ex:8 |
| |
| Generated simple app |
| Checking PLT... |
| [:compiler, :elixir, :kernel, :stdlib] |
| Finding suitable PLTs |
| Looking up modules in dialyxir_erlang-20.2.2_elixir-1.6.0-rc.0_deps-dev.plt |
| . . . |
| Checking 391 modules in dialyxir_erlang-20.2.2_elixir-1.6.0-rc.0_deps-dev.p |
| lt |
| Adding 48 modules to dialyxir_erlang-20.2.2_elixir-1.6.0-rc.0_deps-dev.plt |
| |
| Starting Dialyzer |
| dialyzer args: [ |
| check_plt: false, |
| init_plt: '/Users/dave/Work/Bookshelf/titles/elixir16/Book/code/typespecs |
| /simple/_build/dev/dialyxir_erlang-20.2.2_elixir-1.6.0-rc.0_deps-dev.plt', |
| files_rec: ['/Users/dave/Work/Bookshelf/titles/elixir16/Book/code/typespe |
| cs/simple/_build/dev/lib/simple/ebin'], |
| warnings: [:unknown] |
| ] |
| done in 0m1.18s |
| lib/simple.ex:7: Invalid type specification for function |
| 'Elixir.Simple':count_atoms/1. The success typing is (_) -> 'nil' |
| done (warnings were emitted) |
Ouch! Let’s run it again:
| mix dialyzer --no-check |
| Starting Dialyzer |
| dialyzer args: [ |
| . . . |
| ] |
| done in 0m1.4s |
| |
| lib/simple.ex:7: Invalid type specification for function |
| 'Elixir.Simple':count_atoms/1. The success typing is (_) -> 'nil' |
| done (warnings were emitted) |
Those last three lines are the important ones. They’re complaining that the typespec for count_atoms doesn’t agree with the implementation. The success typing (think of this as the actual type)[43] returns nil, but the spec says it is a nonnegative integer. Dialyzer has caught our stubbed-out body.
Let’s fix that:
| defmodule Simple do |
| @type atom_list :: list(atom) |
| @spec count_atoms(atom_list) :: non_neg_integer |
| def count_atoms(list) do |
| length list |
| end |
| end |
and run dialyzer again:
| $ mix dialyzer |
| Compiling 1 file (.ex) |
| Checking PLT... |
| |
| done in 0m1.34s |
| done (passed successfully) |
Let’s add a second module that calls our count_atoms function:
| defmodule Client do |
| @spec other_function() :: non_neg_integer |
| def other_function do |
| Simple.count_atoms [1, 2, 3] |
| end |
| end |
Compile and dialyze:
| 19∙18∙53≻ mix dialyzer |
| Compiling 1 file (.ex) |
| Generated simple app |
| Checking PLT... |
| done in 0m1.37s |
| |
| lib/simple/client.ex:3: Function other_function/0 has no local return |
| lib/simple/client.ex:4: The call 'Elixir.Simple':count_atoms([1 | 2 |
| | 3,...]) breaks the contract (atom_list()) -> non_neg_integer() |
| done (warnings were emitted) |
That’s pretty cool. Dialyzer noticed that we called count_atoms with a list of integers, but it is specified to receive a list of atoms. It also decided this would raise an error, so the function would never return (that’s the no local return warning). Let’s fix that:
| defmodule Client do |
| @spec other_function() :: non_neg_integer |
| def other_function do |
| Simple.count_atoms [:a, :b, :c] |
| end |
| end |
| $ mix dialyzer |
| Compiling 1 file (.ex) |
| |
| done in 0m1.03s |
| done (passed successfully) |
And so it goes.…
In this appendix, we’ve shown dialyzer working with type specs that we added to our functions. But it also does a credible job with unannotated code. This is because dialyzer knows the types of the built-in functions (remember the long wait the first time we ran it) and can infer (some of) your function types from this. Here’s a simple example:
| defmodule NoSpecs do |
| def length_plus_n(list, n) do |
| length(list) + n |
| end |
| def call_it do |
| length_plus_n(2, 1) |
| end |
| end |
Compile this, and run dialyzer:
| $ mix dialyzer |
| ... |
| done in 0m1.28s |
| |
| lib/nospecs.ex:5: Function call_it/0 has no local return |
| lib/nospecs.ex:6: The call 'Elixir.NoSpecs':length_plus_n(1,2) will |
| never return since it differs in the 1st argument from the success |
| typing arguments: ([any()],number()) |
| done (warnings were emitted) |
Here it noticed that the length_plus_n function called length on its first parameter, and length requires a list as an argument. This means length_plus_n also needs a list argument, and so it complains.
What happens if we change the call to length_plus_n([:a, :b], :c)?
| defmodule NoSpecs do |
| def length_plus_n(list, n) do |
| length(list) + n |
| end |
| def call_it do |
| length_plus_n([1, 2], :c) |
| end |
| end |
| $ mix dialyzer |
| done in 0m1.29s |
| |
| lib/nospecs.ex:5: Function call_it/0 has no local return |
| lib/nospecs.ex:6: The call 'Elixir.NoSpecs':length_plus_n([1, 2],'c') |
| will never return since it differs in the 2nd argument from the |
| success typing arguments: ([any()],number()) |
| done (warnings were emitted) |
This is even cooler. It knows that + (which is implemented as a function) takes two numeric arguments. When we pass an atom as the second parameter, dialyzer recognizes that this makes no sense, and complains. But look at the error. It isn’t complaining about the addition. Instead, it has assigned a default typespec to our function, based on its analysis of what we call inside that function.
This is success typing. Dialyzer attempts to infer the most permissive types that are compatible with the code—it assumes the code is correct until it finds a contradiction. This makes it a powerful tool, as it can make assumptions as it runs.
Does that mean you don’t need @spec attributes? That’s your call. Try it with and without. Often, adding a @spec will further constrain a function’s type signature. We saw this with our count_of_atoms function, where the spec made it explicit that we expected a list of atoms as an argument.
Ultimately, dialyzer is a tool, not a test of your coding chops. Use it as such, but don’t waste time adding specs to get a gold star.