
In our previous post, we covered the basics of setting up and using Dialyzer in your Elixir project. Now, let's dive deeper into advanced type specifications, complex warning patterns, and troubleshooting techniques that will help you make the most of Dialyzer.
Advanced Type Annotations
Advanced type annotations include opaque types, recursive types, and generic types. Let's start by looking at opaque types.
Opaque Types
Opaque types enforce data abstraction by hiding a type's internal structure from external modules. This ensures that any data manipulation occurs solely through a module's public API, thereby safeguarding the integrity of the data.
For example, let's create a Counter
module that uses an opaque type to hide its internal structure.
This allows us to change the implementation details later without affecting code that uses the module:
defmodule Counter do @opaque t :: %__MODULE__{ value: non_neg_integer(), max: pos_integer() } defstruct [:value, :max] @spec new(pos_integer()) :: t() def new(max), do: %__MODULE__{value: 0, max: max} @spec increment(t()) :: {:ok, t()} | {:error, :max_reached} def increment(%__MODULE__{value: value, max: max} = counter) when value < max do {:ok, %{counter | value: value + 1}} end def increment(%__MODULE__{}), do: {:error, :max_reached} end
By marking the type as @opaque
, we ensure that other modules can only interact with our counter through the new/1
and increment/1
functions, preventing direct manipulation of the struct fields.
Recursive Types
Recursive types allow you to define complex, self-referential data structures such as trees or nested expressions. They are particularly useful for modeling hierarchical data and for tasks like evaluating an abstract syntax tree in interpreters or compilers.
Here's an example of using recursive types to define and evaluate a simple arithmetic expression tree:
defmodule AST do @type node :: {:binary, node(), :+| :- | :* | :/, node()} | {:unary, :- | :+, node()} | {:number, number()} @spec evaluate(node()) :: number() def evaluate({:binary, left, op, right}) do apply_op(op, evaluate(left), evaluate(right)) end def evaluate({:unary, op, expr}), do: apply_unary(op, evaluate(expr)) def evaluate({:number, n}), do: n end
By defining the node()
type recursively, we can represent expressions of arbitrary complexity while maintaining type safety.
This helps catch errors like malformed expressions at compile-time rather than runtime.
Generic Types
Generic types help create flexible and reusable code by allowing types to be parameterized. This enables you to write functions and structures that can work with various data types while still ensuring type safety. They're particularly valuable when implementing common patterns like result types, option types, or collection operations.
Let's look at a practical example using generic types to create a type-safe result wrapper:
defmodule Result do @type t(ok, error) :: {:ok, ok} | {:error, error} @spec map(t(a, e), (a -> b)) :: t(b, e) when a: var, b: var, e: var def map({:ok, value}, fun), do: {:ok, fun.(value)} def map({:error, _} = error, _fun), do: error end
The t(ok, error)
type allows us to specify both the success and error types, making it impossible to mix incompatible values.
This pattern is incredibly useful for error handling and data transformation pipelines, ensuring type safety throughout the entire chain of operations.
Advanced Warning Patterns in Elixir
We already examined some common warnings in the first part of this series. Let's examine some of the more complex warning patterns you might encounter, starting with invalid contracts.
1. Invalid Contract
In this example:
defmodule StringHelper do @spec valid_format?(String.t()) :: :ok | :error def valid_format?(str) do pattern = ~r/^[A-Z][a-z]+(-[A-Z][a-z]+)*$/ Regex.match?(pattern, str) end end
This warning occurs because the function returns a boolean (true
or false
) from Regex.match?/2
, but the typespec promises :ok | :error
:
lib/example.ex:17:invalid_contract Invalid type specification for function valid_format?.
Always ensure your typespec matches the actual return value. The correct typespec should be:
@spec valid_format?(String.t()) :: boolean()
2. Function Application Arguments
Let's look at a function application argument:
defmodule Calculator do @spec sum(list(integer())) :: integer() def sum(numbers), do: Enum.sum(numbers) def process() do sum(["1", "2", "3"]) # Passing strings instead of integers end end
This error occurs when passing arguments of incorrect types to functions:
lib/calculator.ex:6: The function call will not succeed. Calculator.sum([<<49>>, <<50>>, <<51>>]) breaks the contract ([integer()]) :: integer()
Always ensure the arguments match the function's typespec. Here, converting strings to integers first would fix the issue.
3. Opaque Type Mismatch
Opaque types are a powerful feature in Elixir, enabling true data abstraction.
When you mark a type as @opaque
, you tell Dialyzer that the type's internal structure should only be visible within the module that defines it.
This is particularly useful for:
- Encapsulation: Hiding implementation details from other modules.
- Data Integrity: Ensuring data is only modified through your module's API.
- API Stability: Changing internal representations without affecting external code.
- Security: Preventing sensitive data from being directly manipulated.
Let's look at an example where we create a secure storage module that encrypts data, but a consumer module incorrectly tries to manipulate the encrypted data directly:
defmodule SecureStorage do @opaque encrypted_data :: binary() @spec encrypt(String.t()) :: encrypted_data() def encrypt(data), do: Base.encode64(data) # Wrong: Another module trying to manipulate opaque type directly defmodule Consumer do def process_data(data) do SecureStorage.encrypt("secret") |> String.split(",") # Error: Treating opaque type as string end end end
Leading to this error:
lib/secure_storage.ex:10:21:call_with_opaque The call String.split('Elixir.SecureStorage':encrypted_data(),<<44>>) contains an opaque term in 1st argument when terms of different types are expected in these positions}.
Opaque types should only be manipulated by the module that defines them. Instead of allowing direct manipulation, provide public functions to interact with opaque types.
4. Range Errors
Dialyzer detects two types of range errors — missing range and extra range.
Missing range is when a typespec contains a larger range than what a function may return. For example:
defmodule Example do @spec ok(boolean()) :: :ok def ok(true), do: :ok def ok(false), do: :error end
So you'll see:
lib/example.ex:missing_range The type specification is missing types returned by function. Function: Example.ok/1 Type specification return types: :ok Missing from spec: :error
Similarly, extra range is when the typespec contains a larger range than what a function may return. For example:
defmodule Example do @spec error(boolean()) :: :ok | :error def error(_book), do: :error end
You get the error:
lib/example.ex:extra_range The type specification has too many types for the function. Function: Example.error/1 Extra type: :ok Success typing: :error
Tips for Working with Dialyzer in Elixir
Dialyzer can sometimes feel daunting, especially if you are just starting with it. Here are some tips for when you are just beginning your Dialyzer journey:
-
Start with broader types and narrow them as needed This approach allows you to catch obvious errors without being overly restrictive, giving you room to adapt your types as you understand your codebase better.
-
Use built-in types like
term()
sparingly Althoughterm()
is very generic, relying on more specific types improves type safety and makes your code self-documenting, helping others understand the expected data shapes. -
Consider using
TypeCheck
alongside Dialyzer for runtime type checking during development Runtime checks withTypeCheck
can complement Dialyzer's static analysis, catching errors that might slip through and providing immediate feedback during development. -
Remember that Dialyzer is success typing — it only reports errors it's certain about This means Dialyzer might miss some potential issues. It's important to combine its analysis with thorough testing and code reviews for comprehensive error detection.
Troubleshooting Dialyzer
While Dialyzer is a powerful tool, you might encounter some challenges when first integrating it into your workflow. When you encounter Dialyzer warnings that are difficult to understand, there are several strategies you can employ. Let's look at output control first.
Output control
Dialyzer includes verbose information by default. You can control the output level:
mix dialyzer --quiet-with-result # Print only the final results mix dialyzer --quiet # No output unless there are errors
Incremental Analysis
For large projects, it can sometimes be helpful to analyze one module at a time.
This can be configured inside mix.exs
by specifying dialyzer.paths
:
# mix.exs def project do [ dialyzer: [ paths: ["lib/specific_module.ex"] ] ] end
Configuration Tweaks
Dialyzer's behavior can be fine-tuned through various configuration options, particularly around Persistent Lookup Table (PLT) management:
# mix.exs def project do [ dialyzer: [ plt_add_deps: :apps_direct, plt_add_apps: [:mix, :ex_unit], plt_ignore_apps: [:mock] ] ] end
Here's what each configuration option does:
-
PLT Dependencies:
-
:plt_add_deps
controls how dependencies are added to your PLT::app_tree
(default) - Includes all transitive OTP dependencies:apps_direct
- Only includes direct OTP dependencies, so is useful for faster analysis
-
:plt_add_apps
lets you include additional applications beyond your direct dependencies -
:plt_ignore_apps
helps exclude specific applications from analysis -
:plt_apps
allows you to specify an exact list of applications, replacing the default list
-
-
PLT File Management:
:plt_core_path
defines where PLT files are stored:plt_file
specifies the PLT file path and options
Pro tip: For team projects, consider storing PLT files in a shared location to speed up analysis across the team.
Next up we'll look at ignoring warnings.
Ignoring Warnings
While it's best to fix all warnings, sometimes you need to suppress specific warnings, especially when migrating a large codebase to use Dialyzer.
The ignore_warnings
configuration helps manage this:
- Generate a list of current warnings in the ignore file format:
mix dialyzer --format ignore_file
- Create
.dialyzer_ignore.exs
with the warnings you want to ignore:
[ {"lib/example.ex", :call}, {"lib/secure_storage.ex", :call_with_opaque} ]
- Configure Dialyzer to use this file:
# mix.exs def project do [ dialyzer: [ ignore_warnings: ".dialyzer_ignore.exs" ] ] end
This approach helps you get a passing Dialyzer configuration that you can gradually improve as you fix underlying issues.
And that's that!
Wrapping Up
As we've explored in this deep dive, Dialyzer's advanced features provide powerful tools for maintaining code quality. Through proper use of advanced types, you can achieve better encapsulation with opaque types, model complex data structures using recursive types, and create flexible, reusable code with generic types. These type features form the foundation of robust Elixir applications.
While there's a steep learning curve involved in mastering Dialyzer's advanced features, the investment pays off in more maintainable, better-documented, and more reliable code. Spending time upfront on proper type specifications and understanding Dialyzer's intricacies will save you countless hours of debugging and maintenance in the future.
Happy debugging!
Wondering what you can do next?
Finished this article? Here are a few more things you can do:
- Subscribe to our Elixir Alchemy newsletter and never miss an article again.
- Start monitoring your Elixir app with AppSignal.
- Share this article on social media
Most popular Elixir articles
A Complete Guide to Phoenix for Elixir Monitoring with AppSignal
Let's set up monitoring and error reporting for a Phoenix application using AppSignal.
See moreEnhancing Your Elixir Codebase with Gleam
Let's look at the benefits of using Gleam and then add Gleam code to an Elixir project.
See moreUsing Dependency Injection in Elixir
Dependency injection can prove useful in Elixir. In this first part of a two-part series, we'll look at some basic concepts, core principles, and types of dependency injection.
See more

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 GoyalBecome our next author!
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!
