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

  1. Add dialyxir to Dependencies in mix.exs

    After adding the dependency, don’t forget to update your deps.

    defp deps do
      [
        {:dialyxir, "~> 1.4", only: :dev, runtime: false}
      ]
    end
    
  2. Build the PLT Creating a Persistent Lookup Table (PLT) can be time-consuming, especially on the first run.

    mix dialyzer --plt
    
  3. 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

  1. Navigate to the project’s root directory.
  2. Access the .git/hooks directory. Add or modify the pre-push file.
  3. 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
    
  4. 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.