Typespecs and Dialyxir

Functional languages depend heavily on types to determine how functions interact with one another. You can dramatically improve a function’s declaration of intent with typespecs. A typespec annotates the expected input and outputs of a function. Typespecs aren’t required, but they may be worthwhile because they require the developer to explicitly state what a function accepts and what it returns.

Since many bugs creep in at system boundaries such as function interfaces, declaring and enforcing types when you make your function definitions lets you find bugs and improve documentation for your programs. Typespecs are a consistent and repeatable way to document your system and decrease bugs. They’re especially useful for teams adopting functional languages for the first time:

  • To use them effectively, programmers must reason through how their functions interact.

  • They help tools find bugs that tests might not.

In this section, we’ll show you how to write typespecs and use them to find bugs in your programs. We’ll work through some code examples, use some Elixir type specs, and then use an automated tool called Dialyxir to look for type bugs.

Conscious Coding

By default, Elixir checks the arity, or the number of arguments each function requires. A type spec is an extra annotation for a function that does more. Creating one is declaring your intent that your function takes specific types as arguments and produces a return value of a specific type.

Think of a function that adds two integers and returns the sum. Here’s the function:

 def​ add(x, y), ​do​: x + y

Both inputs and the output might be of type integer(), so your typespec would look like this:

 @spec add(integer(), integer()) :: integer()
 def​ add(x, y), ​do​: x + y

Comments and documentation are subjective opinions; typespecs are objective facts. Many programmers think of types as a hurdle to satisfy compilers. But types can be a great communication tool when extended to your domain.

Imagine you are writing a function that computes the distance between two points:

 @spec distance({number(), number()}, {number(), number()}) :: float()
 def​ distance({x1, y1}, {x2, y2}) ​do
 :math​.sqrt(​:math​.pow(x2 - x1, 2) + ​:math​.pow(y2 - y1, 2))
 end

The typespec tells us about the inputs and outputs but it is devoid of any domain knowledge. That makes it hard to read and even full of duplication, as seen in the {number(), number()} tuple.

Let’s rewrite it to rely on user-defined types:

 @type point() :: {number(), number()}
 @type distance() :: float()
 
 @spec distance(point(), point()) :: distance()
 def​ distance({x1, y1}, {x2, y2}) ​do
 :math​.sqrt(​:math​.pow(x2 - x1, 2) + ​:math​.pow(y2 - y1, 2))
 end

User-defined types have more semantic meaning than the default Elixir types for the developers reading and writing the code. As the module grows, you will re-use those types, leading to clearer and more understandable code. The full reference for typespecs in Elixir can be found in the Elixir documentation.[12] They are interesting on their own, but get more useful once you start automating error checking.

Dialyxir

Since Elixir is a dynamically typed language, the compiler doesn’t bother to evaluate whether your typespecs are correct. The compiler only cares if the number of function arguments, or arity, and function name match.

Typespecs don’t seem useful; you can remove them without upsetting the compiler. You might ask yourself why you’d ever go through the effort for extraneous typespecs. Worse, typespecs could easily fall out of alignment with the functions they support and lead to confusion. We need some kind of tool to automate type checking just as Credo automates style checks.

Jeremy Huffman[13] has written a library called Dialyxir,[14] which is a set of easy-to-use mix tasks for Dialyzer, an Erlang tool named from the characters in DIscrepancy AnaLYZer for ERlang. The tool actually analyzes your code for type consistency using your typespecs for extra information. Let’s give Dialyxir a try. In the same hexify project from the previous section, add Dialyxir to your dependencies in mix.exs:

 defp​ deps ​do
  [
  {​:credo​, ​"​​~> 0.8.8"​, ​only:​ [​:dev​], ​runtime:​ false},
  {​:dialyxir​, ​"​​~> 0.5"​, ​only:​ [​:dev​], ​runtime:​ false}
  ]
 end

and now let’s configure it:

 def​ project ​do
  [
 app:​ ​:belief_structure​,
 dialyzer:​ [​plt_add_deps:​ ​:transitive​],

You’re likely wondering what plt does. The Persistent Lookup Table (PLT) is a compiled cache containing the analysis of your application. Otherwise, running Dialyxir would take ages. First run:

 > mix dialyzer

Wait some time for it to run. And continue to wait a bit more. You’ll eventually need to cancel it. And now you understand why building this cache is important.

Let’s add some incorrect specs to see what Dialyxir says. Crack open hexify.ex again, and add these typespecs:

 defmodule​ BeliefStructure.Hexify ​do
  @spec name(integer) :: integer
 def​ name(package) ​do
  package(package)
 end
 
  @spec package(boolean) :: boolean
 def​ package(package) ​do
  package <> ​"​​_ex"
 end
 end

After mix dialyzer, you’ll clearly see what’s broken:

  >>​​ ​​mix​​ ​​dialyzer
 
 ...
  hexify.ex:2: Invalid type specification
  for function 'Elixir.BeliefStructure.Hexify':name/1.
  The success typing is (binary()) -> <<_:24,_:_*8>>
  hexify.ex:7: Invalid type specification
  for function 'Elixir.BeliefStructure.Hexify':package/1.
  The success typing is (binary()) -> <<_:24,_:_*8>>
 
  done in 0m1.12s
  done (warnings were emitted)

Mercifully this only took 0m1.12s to analyze. The Invalid type specification warning shows that the both name/1 and package/1 expect a binary. You can infer both from the code, but Dialyxir makes it explicit. Fix the specs and rerun Dialyxir, like this:

 defmodule​ BeliefStructure.Hexify ​do
  @spec name(String.t) :: String.t
 def​ name(package) ​do
  package(package )
 end
 
  @spec package(String.t) :: String.t
 defp​ package( package ) ​do
  package <> ​"​​_ex"
 end
 end

Now when you run mix dialyzer, you get a successful report. All our types are correct, and we’re confident these functions expect a string and return a string. These typespecs, in turn, lead to better tests and better documentation.

One thing to be aware of is that Dialyzer warnings can be difficult to understand and troubleshoot. You can potentially lose a lot of time trying to sort out somewhat cryptic errors, and it could even spook some of your team while they are learning Elixir. Take a look at the following warnings emitted by Dialyzer:

 1. Function handle_cast/2 has no local return
 
 2. The return type tuple() in the specification of init/1 is not a
  subtype of 'ignore' | {'ok',_} | {'stop',_} | {'ok',_,'hibernate' |
  'infinity' | non_neg_integer()}, which is the expected return type
  for the callback of 'Elixir.GenServer' behaviour

Both of them are relatively easily solved. For the no local return warning, you must explicitly declare the handle_cast/2 function will fail by adding no_return() as the return type of its @spec. The second warning happens when the return type of your @spec does not match the return type defined for the callback by Elixir’s GenServer.

The point is, though, that these kinds of warnings can be overwhelming if you’re retroactively adding in the typespecs or you’ve been adding typespecs along the way without testing them against Dialyzer.

Finally, even if you spec all your functions in your application correctly, warnings might still pop up from external libraries you include in your application, just as you’d get compiler warnings from included libraries.

Dialyzer does require an explicit step to run and it produces cryptic errors, so it is an acquired taste. But if you tend to like the safety types can offer, typespecs with Dialyzer may provide just enough support for you. We will explore one of those areas next. Let’s talk about documentation.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset