Dialyzer and Elixir: Add static code analysis
This post is part of the Elixir Setup Series, covering essential tools and practices for Elixir projects.
While discussions about strong typing continue, Elixir remains a dynamically typed language built on the Erlang VM (BEAM). It inherits typespecs from Erlang, which provide optional type annotations to document code and enable static analysis tools like Dialyzer.
Dialyzer
Dialyzer is a static code analysis tool that offers several benefits:
- Early Detection: Identifies discrepancies early, reducing the cost and effort of fixing bugs later.
- Consistency: Promotes consistency in function interfaces and return values.
- Documentation: Acts as live documentation, providing clear insights into the expected behavior of functions.
- Performance Insights: Typespecs can reveal performance-related design flaws that might go unnoticed during testing, especially in complex transformations.
Note that Dialyzer utilizes success typing, which identifies potential failure paths rather than guaranteeing code correctness.
Integrate Early
As with any optional typing system or code analysis tool, it is easier to integrate Dialyzer early in a project.
Adding Dialyzer
-
Add
dialyxir
to Dependencies inmix.exs
After adding the dependency, don’t forget to update your deps.
defp deps do [ {:dialyxir, "~> 1.4", only: :dev, runtime: false} ] end
-
Build the PLT Creating a Persistent Lookup Table (PLT) can be time-consuming, especially on the first run.
mix dialyzer --plt
-
Run Dialyzer Running Dialyzer for the first time may also take a while.
mix dialyzer
Integrating Dialyzer into the Makefile
While you can use the mix
file’s aliases
, I prefer using a Makefile to manage tasks.
setup.dev:
@echo "Getting deps for development environment..."
MIX_ENV=dev mix deps.get
@echo "Creating database for development environment..."
MIX_ENV=dev mix ecto.create
@echo "Running migrations for development environment..."
MIX_ENV=dev mix ecto.migrate
@echo "Adding dialyzer..."
MIX_ENV=dev mix dialyzer --plt
lint:
@echo "Linting code..."
@echo "Running Dialyzer..."
MIX_ENV=dev mix dialyzer
Adding Dialyzer to CI/CD
Dialyzer should be part of your CI/CD pipeline. It’s also helpful to incorporate Dialyzer into your development workflow with a Git pre-push hook.
Create the Hook
- Navigate to the project’s root directory.
- Access the
.git/hooks
directory. Add or modify thepre-push
file. -
Add the following script to the
pre-push
file:#!/bin/bash # Run Dialyzer echo "Running Dialyzer to check for type discrepancies..." mix dialyzer # Capture the exit status of Dialyzer RESULT=$? # Evaluate the result if [ $RESULT -ne 0 ]; then echo "Dialyzer found discrepancies or issues." echo "Please fix them before pushing!" exit 1 fi echo "No discrepancies found. Proceeding with push." exit 0
-
Make the pre-push script executable:
chmod +x pre-push
Player Module
Add a @typedoc
to include information about a Player to your documentation.
@typedoc """
Type representing the player struct with fields id, name, email, score, inserted_at, and updated_at.
"""
The Player
module defines an Ecto schema
, which automatically generates the corresponding struct. By convention, the type representing this struct is named t
, so the Player
struct type is referenced in other modules as Player.t()
.
@type t :: %__MODULE__{
id: integer(),
name: String.t(),
email: String.t(),
score: integer(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
schema "players" do
field :name, :string
field :email, :string
field :score, :integer, default: 0
timestamps()
end
The specifications (@spec
) define a function’s inputs and outputs.
create_changeset/1
@spec create_changeset(map()) :: Ecto.Changeset.t()
def create_changeset(attrs) do
%__MODULE__{}
|> cast(attrs, [:name, :email, :score])
|> validate_required([:name, :email])
|> validate_email()
|> unique_constraint(:email)
|> put_change_if_nil(:score, 0)
|> validate_score_non_negative()
end
This takes a map
of values and returns an Ecto.Changeset
.
update_changeset/2
@spec update_changeset(t(), map()) :: Ecto.Changeset.t()
def update_changeset(player, attrs \\ %{}) do
player
|> cast(attrs, [:name, :score])
|> validate_required([:name, :score])
|> validate_score_not_decrease()
end
This function takes a player struct (t()
) and a map
of attributes, returning an Ecto.Changeset
, which determines if the change is valid.
Validity Functions
Validity functions take an Ecto.Changeset
and return an Ecto.Changeset
, allowing them to be chained together.
@spec validate_email(Ecto.Changeset.t()) :: Ecto.Changeset.t()
defp validate_email(changeset) do
validate_format(changeset, :email, ~r/@/, message: "must contain @")
end
Account Module
The Account
module queries the database for players.
list_players/0
@spec list_players() :: [Player.t()]
def list_players do
Repo.all(Player)
end
This function queries the database for all players. The @spec
defines that it takes no arguments and returns a list of Player
structs.
get_player!/1
@spec get_player!(integer()) :: Player.t()
def get_player!(id), do: Repo.get!(Player, id)
This function takes an integer and returns the Player
struct that matches the given id
. There is no way to type that a function may fail in Dialyzer, so by convention the Elixir community uses the exclamation mark (!
) suffix and documentation to indicate the function’s control flow includes raising an error.
create_player/1
@spec create_player(map()) :: {:ok, Player.t()} | {:error, Ecto.Changeset.t()}
def create_player(attrs \\ %{}) do
attrs
|> Player.create_changeset()
|> Repo.insert()
end
This function takes a map of values representing a new player and returns either {:ok, Player.t()}
or {:error, Ecto.Changeset.t()}
. The error result includes the changeset, which contains the reasons the operation failed, such as missing or invalid values.