elixir

Getting Started with Dialyzer in Elixir

Pulkit Goyal

Pulkit Goyal on

Getting Started with Dialyzer in Elixir

Dialyzer (DIscrepancy AnaLYZer for ERlang programs) is a powerful static analysis tool that helps developers identify potential issues in their Elixir code without executing it. It excels at finding type mismatches, unreachable code, and unnecessary functions through sophisticated flow analysis.

In part one of this two-part series, we'll first get to grips with the basics of Dialyzer. In part two, we'll examine more advanced use cases.

An Introduction to Dialyzer for Elixir

In the world of dynamic languages like Elixir, type-related issues traditionally only surface during runtime, making them harder to catch during development. Dialyzer bridges this gap by analyzing your code's type specifications and usage patterns to detect potential problems before they reach production.

When integrated into your development workflow, Dialyzer provides several key benefits:

  1. Enhanced Code Reliability: Catch type-related bugs early in the development cycle.
  2. Better Documentation: Type specifications serve as living documentation and provide useful hints with IDE integration.
  3. Improved Developer Productivity: Identify issues before they cause runtime errors.
  4. Safer Refactoring: Get immediate feedback about type-related changes.

Setting Up Dialyzer for Elixir

While Dialyzer itself comes with Erlang, the recommended way to use it in Elixir projects is through dialyxir, a mix task that makes Dialyzer more convenient to use.

First, add dialyxir to your project's dependencies in mix.exs:

Elixir
def deps do [ {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, ] end

After adding the dependency, install it by running:

Shell
mix deps.get

Now you can run Dialyzer with:

Shell
mix dialyzer

On the first run, Dialyzer will build a Persistent Lookup Table (PLT), which contains information about your project and its dependencies. This process might take several minutes, but it's a one-time operation. The PLT will be cached and reused in subsequent runs.

The output will look something like this:

Shell
Compiling PLT .dialyzer_plt Starting Dialyzer Total errors: 0, Skipped: 0, Unnecessary Skips: 0 done in 0m1.34s done (passed successfully)

If Dialyzer finds issues, it will display warnings like:

Shell
lib/example.ex:2:invalid_contract The @spec for the function does not match the success typing of the function. Function: Example.hello/0 Success typing: @spec hello() :: :error

Although Dialyzer can infer types from your code, adding explicit type specifications helps it perform a more thorough analysis. We'll explore how to write effective type specifications in the next section.

Types and Type Specifications in Elixir

Type specifications serve multiple purposes in Elixir. Beyond documentation, they provide Dialyzer with the information needed to perform sophisticated flow analysis. As someone who's worked extensively with Elixir in production, I've found that well-designed type specifications:

  1. Act as executable documentation
  2. Enable early bug detection
  3. Make refactoring safer
  4. Improve IDE integration

Adding Typespecs to Your Elixir Code

Let's start with the basics and progress to more complex scenarios. There are two main type attributes you'll use frequently:

  1. @type name :: <<type scpecification>> defines a type with the specified name.
  2. @spec function_name(argument_types) :: return_type defines a function specification.

There are several built-in types like any(), none(), atom(), map(), and integer().

It is also possible to compose types using list(type), nonempty_list(type), etc.

You can use types defined in other modules as well. It is quite common for data structures to define their types named t. In fact, several Elixir data structures already define types that can be used in your custom typespecs, like String.t() and Range.t().

Finally, we can use almost any literal as a type that restricts the allowed values to be like the literal. For example, 1, 1..10, {:ok, type}.

We'll cover advanced attributes to create other special types in the second and final part of this series.

For now, here's an example of using some typespecs in practice:

Elixir
defmodule UserAccount do @type status :: :active | :inactive | :suspended @type role :: :admin | :user @spec create_user(String.t(), status(), role()) :: {:ok, map()} | {:error, String.t()} def create_user(name, status, role) do # Implementation end @spec is_active?(status()) :: boolean() def is_active?(status), do: status == :active end

In this example:

  • We define custom types status and role using union types
  • The create_user function specification ensures that arguments are of the correct types
  • Dialyzer will catch type mismatches, like passing a number instead of a string for a name

We'll explore more advanced type attributes like @opaque in the next post.

Understanding Common Dialyzer Warnings

Now that we have Dialyzer set up and running, let's explore the most common warnings you'll encounter and learn how to address them effectively.

1. Match Errors

Dialyzer detects unused pattern matches by analyzing your types:

Elixir
defmodule Example do def ok() do unmatched(:ok) end defp unmatched(:ok), do: :ok defp unmatched(:error), do: :error end
Shell
The pattern can never match the type. Pattern: :error Type: :ok

2. Redundant Guard Clauses

This warning shows Dialyzer's ability to detect impossible conditions:

Elixir
defmodule Example do def to_string(%{} = a) when is_binary(a) do inspect(a) end end
Shell
# lib/example.ex:2:31:guard_fail The guard clause can never succeed.

Here, we're pattern matching on %{} (a map) but then using an is_binary/1 guard — these conditions can never be true simultaneously since a value cannot be both a map and a binary. Such contradictions often indicate logical errors in your code's flow control. Remove the contradictory guard or fix the pattern matching based on your intended behavior.

3. Unmatched Returns

This warning is particularly important for maintaining robust error handling:

Elixir
defmodule Example do def ok() do :rand.uniform(100) |> validate() :ok end defp validate(n) when n < 50, do: :ok defp validate(n) when n > 50, do: {:error, "too big"} end
Shell
lib/example.ex:unmatched_return The expression produces a value of type: :ok | {:error, <<_::56>>} but this value is unmatched.

Dialyzer has detected that the function returns a union type (:ok | {:error, binary()}) that we are ignoring. In Elixir, it's a best practice to handle all possible return values, especially errors.

To fix this, you should:

  1. Pattern match on the result and handle both cases, or
  2. Use with expressions for cleaner error handling, or
  3. Explicitly discard the result using _ = if that's truly intended

4. No Local Return

This indicates that your function never returns normally — it either raises an exception or runs indefinitely:

Elixir
defmodule Example do def ok() do Enum.each([1, 2, 3], fn _ -> raise "error" end) end end
Shell
lib/example.ex:no_return The created anonymous function only terminates with explicit exception.

This is common in placeholder code, but in production, you should ensure your functions have proper return paths.

If the function is meant to be raised, specify no_return() in the typespec:

Elixir
@spec do_something() :: no_return()

In addition to obvious no-returns, Dialyzer often also raises this as a side-effect of other errors that lead to a guaranteed exception.

For example, the following code tries to pass age as a string when it's declared type is an integer, leading to a Dialyzer no_return error as a side-effect of the call error.

Elixir
defmodule User do @type t :: %__MODULE__{name: String.t(), age: integer()} defstruct [:name, :age] @spec get_age(User.t()) :: integer() def get_age(user), do: user.age end defmodule Example do def do_something() do User.get_age(%User{name: "User", age: "20"}) end end
Shell
lib/example.ex:10:7:no_return Function do_something/0 has no local return. lib/example.ex:11:18:call The function call get_age will not succeed.

This can sometimes also happen when a library has incorrect typespecs or complex types that Dialyzer has trouble inferring.

In such cases, it can be desirable to skip Dialyzer warnings. This can be achieved by using a special @dialyzer attribute to disable warnings, specifying a tuple with the warning type and the fuction/arity.

Elixir
defmodule Example do @dialyzer {:no_return, do_something: 0} def do_something() do User.get_age(%User{name: "User", age: "20"}) end end

And that's it for this part of our two-part series!

Wrapping Up

Getting started with Dialyzer might seem daunting at first, but the benefits are worth the initial setup time. As you've seen, it can catch many common issues before they reach production.

If you already have a large project without Dialyzer, the trick is to start small. First:

  • Add Dialyzer to your development dependencies
  • Begin with a single module
  • Add type specifications gradually

In our next and final part of this series, we'll dive deeper into advanced type specifications, explore sophisticated troubleshooting techniques, and learn how to handle complex scenarios that you might encounter as your codebase grows.

We'll also look at how to effectively configure Dialyzer for larger projects and establish team-wide practices for maintaining type specifications.

Until next time!

P.S. If you'd like to read Elixir Alchemy posts as soon as they get off the press, subscribe to our Elixir Alchemy newsletter and never miss a single post!

Wondering what you can do next?

Finished this article? Here are a few more things you can do:

  • Share this article on social media
Pulkit Goyal

Pulkit Goyal

Our guest author Pulkit is a senior full-stack engineer and consultant. In his free time, he writes about his experiences on his blog.

All articles by Pulkit Goyal

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps