As already mentioned in the beginning of this chapter, Elixir is a dynamic programming language. As such, we don't declare the type of each variable, as it depends on the value each variable is bound to at each moment.
Usually dynamic programming languages yield higher productivity, as programmers don't need to declare types and can focus on developing the logic of an application. However, this comes at a cost: Certain errors, which in statically-typed languages would be caught at compile-time, may only be caught at runtime in dynamic languages. The time saved by using a dynamic language is then used (often in excess) on debugging in production.
We're not advocating for statically-typed languages–this book is about Elixir, after all. But what if you could have the best of both worlds?
It turns out you can! Type specifications, or typespecs, are a mechanism to annotate function signatures with the respective types (arguments and return values). Typespecs are also used to create custom data types. Let's explore them in more detail, before jumping into behaviours.
Typespecs are written using the @spec
module directive, followed by function_name(argument_type) :: return_type
. This module directive is placed right before the definition of the function we're annotating.
To demonstrate how to apply typespecs to a function, let's bring back our palindrome?
function:
$ cat examples/string_helper.ex defmodule StringHelper do def palindrome?(term) do String.reverse(term) == term end end
Given that the module name is StringHelper
, and that it's using functions from the String
module, we can see that this function receives a string. As it uses the ==
operator, and also hinted at by the trailing ?
on the function name, we know this function returns a Boolean. With this information, writing the typespec for this function is straightforward:
$ cat examples/string_helper_palindrome_with_typespec.ex defmodule StringHelper do @spec palindrome?(String.t) :: boolean def palindrome?(term) do String.reverse(term) == term end end
You can also define your own types to use in typespecs, by using the @type
directive within a module (usually the module where that type is used the most). Let's say that you frequently use a tuple in your module that contains the name of a country on the first element, and its population on the second, as in {"Portugal, 10_309_573}
. You can create a type for this data structure with this:
@type country_with_population :: {String.t, integer}
Then, you could use country_with_population
in typespecs as you'd use any other type.
Defining a type in a module will export it, making it available to all modules. If you want your type to be private to the module where it's defined, use the @typep
directive. Similar to the @moduledoc
and @doc
directives (to document modules and functions, respectively), you can use the @typedoc
directive to provide documentation for a certain type.
Note
In typespecs, the string type is usually represented by String.t
. You can also use string
but the Elixir community discourages this, with the goal of avoiding confusion with the charlist
type, which represents strings in Erlang. If you use string
in typespecs, the compiler will emit a warning.
Typespecs also provide great documentation, as we can quickly grasp what types this function accepts and returns. This example only shows a subset of the types you can use–please refer to the official documentation to get a full list, at
https://hexdocs.pm/elixir/typespecs.html.
Dialyzer (http://erlang.org/doc/man/dialyzer.html) is a tool that ships with Erlang and performs static analysis of code. It analyses compiled .beam
files, making it available for all programming languages that run on the Erlang VM (such as Elixir!). While Dialyzer can be helpful on projects that don't have typespecs (as it can, for instance, find redundant code), its power is maximized on projects that have their functions annotated with typespecs. This way, Dialyzer is able to report on typing errors, which brings you closer to the security you can get on a statically-typed language.
Although we won't be exploring Dialyzer in this book, we highly recommend its usage, as it can be very helpful. Particularly, we feel that the Dialyxir
library (https://github.com/jeremyjh/dialyxir), is a great way to integrate Dialyzer into Elixir projects, as it abstracts away part of the complexity of dealing with Dialyzer directly.
Behaviours provide a way to describe a set of functions that have to be implemented by a module, while also ensuring that the module implements the functions in that set. If you come from an object-oriented programming background, you can think of behaviours as abstract base classes that define interfaces. After declaring the behaviour, we can then create other modules that adopt this behaviour. A behaviour creates an explicit contract, which states what the modules that adopt the behaviour need to implement. This way, we can have our business logic tied to abstractions (the behaviour) instead of concrete implementations. We can swap two implementations of the same behaviour with very little and localized change in our application–the place where we define the implementation of the behaviour we're going to use.
Let's demonstrate this concept with an example. We'll define a behaviour, called Presenter
, that has only one function: present
. We'll then define a module that adopts this behaviour, called CLIPresenter
. We could also have other modules that would adopt this behaviour, such as a GraphicalPresenter
. A behaviour is created using the @callback
directive inside a module, providing a typespec for each function this behaviour contains. Thus, to define our Presenter
behaviour, we use the following:
$ cat examples/presenter.ex defmodule Presenter do @callback present(String.t) :: atom end
And now we define the module that will adopt this behaviour:
$ cat examples/cli_presenter.ex defmodule CLIPresenter do @behaviour Presenter @impl true def present(text) do IO.puts(text) end end
We can see this module working in the next snippet:
iex> CLIPresenter.present("printing text to the command line") printing text to the command line :ok
From Elixir 1.5 onwards, we may use the @impl
directive to mark the functions that are implemented as callbacks for a behaviour. In our case, we'd put the @impl
directive on top of the present
function:
$ cat examples/cli_presenter_with_impl_annotation.ex defmodule CLIPresenter do @behaviour Presenter @impl true def present(text) do IO.puts(text) end end
We can be even more specific and use @impl Presenter
, to state that this function is a callback from the Presenter
behaviour. This brings two major advantages:
- Increased readability, as it's now explicit which functions make up part of our API and which functions are callback implementations.
- Greater consistency, as the Elixir compiler will check whether the functions you're marking with
@impl
are part of a behaviour your module is adopting.
Note that when you set @impl
on a module, you must set it on all callback functions on that module, otherwise a warning will be issued. Furthermore, you can only use @impl
on functions that are callbacks, otherwise a compilation warning will be issued as well.
Note
You can use @optional_callbacks
to mark one or more functions as optional when adopting a certain behaviour. You need to provide a keyword list, with function names as keys, and arity as the value. If we wanted our present/1
function to be optional in the Presenter
behaviour, we would use:$ cat examples/presenter_with_optional_callbacks.ex
defmodule Presenter do
@callback present(String.t) :: atom
@optional_callbacks present: 1
end
If we didn't implement all of the functions declared in the Presenter
behaviour (by commenting the CLIPresenter.present/1
function, for instance), the Elixir compiler would emit the following warning:
warning: undefined behaviour function present/1 (for behaviour Presenter)
Behaviours help us follow the open/closed principle, since we can extend our system without modifying what we already have. If, in the future, we would need a new type of presenter, we'd just have to create a new module that adopts the Presenter
behaviour, without having to change the behaviour or its current implementations. We'll be using behaviours in the application we'll build in this book, and you'll see them in action again in the next chapter.