20 Typespecs and behaviours

{% include toc.html %}

20.1 Types and specs

Elixir is a dynamically typed language, so all types in Elixir are inferred by the runtime. Nonetheless, Elixir comes with typespecs, which are a notation used for:

  1. declaring custom data types;
  2. declaring typed function signatures (specifications).

20.1.1 Function specifications

By default, Elixir provides some basic types, such as integer or pid, as well as more complex types: for example, the round/1 function, which rounds a float to its nearest integer, takes a number as an argument (an integer or a float) and returns an integer. As you can see in its documentation, round/1‘s typed signature is written as:

round(number) :: integer

:: means that the function on the left side returns a value whose type is what’s on the right side. Function specs are written with the @spec directive, placed right before the function definition. The round/1 function could be written as:

@spec round(number) :: integer
def round(number), do: # implementation...

Elixir supports compound types as well. For example, a list of integers has type [integer]. You can see all the types provided by Elixir in the typespecs docs.

20.1.2 Defining custom types

While Elixir provides a lot of useful built-in types, it’s convenient to define custom types when appropriate. This can be done when defining modules modules through the @type directive.

Say we have a LousyCalculator module, which performs the usual arithmetic operations (sum, product and so on) but, instead of returning numbers, it returns tuples with the result of an operation as the first element and a random offense as the second element.

defmodule LousyCalculator do
  @spec add(number, number) :: {number, String.t}
  def add(x, y), do: {x + y, "You need a calculator to do that?!"}

  @spec multiply(number, number) :: {number, String.t}
  def multiply(x, y), do: {x * y, "Jeez, come on!"}
end

As you can see in the example, tuples are a compound type and each tuple is identified by the types inside it. To understand why String.t is not written as string, have another look at the typespecs docs.

Defining function specs this way works, but it quickly becomes annoying since we’re repeating the type {number, String.t} over and over. We can use the @type directory in order to declare our own custom type.

defmodule LousyCalculator do
  @typedoc """
  Just a number followed by a string.
  """
  @type number_with_offense :: {number, String.t}

  @spec add(number, number) :: number_with_offense
  def add(x, y), do: {x + y, "You need a calculator to do that?!"}

  @spec multiply(number, number) :: number_with_offense
  def multiply(x, y), do: {x * y, "Jeez, come on!"}
end

The @typedoc directive, similarly to the @doc and @moduledoc directives, is used to document custom types.

Custom types defined through @type are exported and available outside the module they’re defined in:

defmodule PoliteCalculator do
  @spec add(number, number) :: number
  def add(x, y), do: make_polite(LousyCalculator.add(x, y))

  @spec make_polite(LousyCalculator.number_with_offense) :: number
  defp make_polite({num, _offense}), do: num
end

If you want to keep a custom type private, you can use the @typep directive instead of @type.

20.1.3 Static code analysis

Typespecs are not only useful to developers and as additional documentation. The Erlang tool Dialyzer, for example, uses typespecs in order to perform static analysis of code. That’s why, in the PoliteCalculator example, we wrote a spec for the make_polite/1 function even if it was defined as a private function.

20.2 Behaviours

Many modules share the same public API. Take a look at Plug, which, as it description states, is a specification for composable modules in web applications. Each plug is a module which has to implement at least two public functions: init/1 and call/2.

Behaviors provide a way to:

  • define a set of functions that have to be implemented by a module;
  • ensure that a module implements all the functions in that set.

If you have to, you can think of behaviours like interfaces in object oriented languages like Java: a set of function signatures that a module has to implement.

20.2.1 Defining behaviours

Say we have want to implement a bunch of parsers, each parsing structured data: for example, a JSON parser and a YAML parser. Each of these two parsers will behave the same way: both will provide a parse/1 function and a extensions/0 function. The parse/1 function will return an Elixir representation of the structured data, while the extensions/0 function will return a list of file extensions that can be used for each type of data (e.g., .json for JSON files).

We can create a Parser behaviour:

defmodule Parser do
  use Behaviour

  defcallback parse(String.t) :: any
  defcallback extensions() :: [String.t]
end

Modules adopting the Parser behaviour will have to implement all the functions defined with defcallback. As you can see, defcallback expects a function name but also a function specification like the ones used with the @spec directive we saw above.

20.2.2 Adopting behaviours

Adopting a behaviour is straightforward:

defmodule JSONParser do
  @behaviour Parser

  def parse(str), do: # ... parse JSON
  def extensions, do: ["json"]
end
defmodule YAMLParser do
  @behaviour Parser

  def parse(str), do: # ... parse YAML
  def extensions, do: ["yml"]
end

If a module adopting a given behaviour doesn’t implement one of the callbacks required by that behaviour, a compile-time warning will be generated.