Dialyzer confuses people because it is not what they expect. It is not a type checker like TypeScript. It does not reject your code unless it can prove it wrong. That is success typing, and once you accept it, Dialyzer becomes an unusually high-signal tool β it reports bugs that nobody writes tests for.
#What Dialyzer actually catches
Three categories:
- Contract violations β a function annotated
@spec f(integer) :: atomthat sometimes returns a string. - Impossible branches β a pattern match that can never succeed given the types flowing in (e.g. matching
{:ok, _}on a function that only returns:error). - Dead code via upstream guarantees β a
caseclause fornilthat the caller never produces.
These are bugs your tests usually do not cover because they require you to think about the input set. Dialyzer does the thinking.
The typespec is the contract
@spec search(query :: String.t(), opts :: keyword()) ::
{:ok, [map()]} | {:error, :timeout | :rate_limited | {:transport, String.t()}}
def search(query, opts) do
...
endThe value of this spec is not documentation β it is that Dialyzer can now verify every caller handles the error cases. A caller that only pattern-matches on {:ok, _} becomes a Dialyzer warning: βthe {:error, _} return is never matchedβ. That is exactly the bug that bites you in production six months later.
#PLT: the up-front cost
Dialyzerβs Persistent Lookup Table caches type information for OTP, Elixir, and your deps. Building it on a fresh checkout takes 3β5 minutes. After that, incremental runs are fast (10β30 seconds for a reasonable module change). Cache the PLT between CI runs or Dialyzer becomes a time sink.
#The rule that makes Dialyzer worth it
@specevery public function inlib/. Leave private functions to inference unless Dialyzer complains.
Public functions are where callers cross module boundaries. Spec them and Dialyzer can reason about the whole call graph. Private functions are usually small enough that inference gets the right answer without help.
#When Dialyzer is wrong
Dialyzer is sometimes wrong β it has false negatives (bugs it will not catch) and, rarely, false positives (warnings you cannot fix without contorting the code). For the latter, @dialyzer {:nowarn_function, [name: arity]} is an acceptable escape hatch, but every use should come with a comment explaining why. A silent nowarn is just a bug waiting to happen.
#Where to go next
- Academy: {{ cross_link(path=β/academy/learn/development-workflowβ, text=βDevelopment Workflowβ) }} β where Dialyzer runs in the pipeline
- Glossary: Dialyzer, Typespec, Success Typing, Static Analysis, Correctness
Not a type checker. A bug finder. Treat it as the second, run it every CI, and the bugs it finds are the ones you were going to miss.