Dialyzer Specs and Success Typing
Engineering

Dialyzer, @spec, and the Shape of Correctness

Dialyzer is not a type checker. It's a proof assistant that accepts anything you can't prove wrong β€” and that is exactly what you want for a 94-app umbrella. Here's how Prismatic uses @spec and success typing without it becoming busywork.

Apr 09, 2026 Β· 7 min read Β· TomΓ‘Ε‘ Korcak (korczis)

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:

  1. Contract violations β€” a function annotated @spec f(integer) :: atom that sometimes returns a string.
  2. 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).
  3. Dead code via upstream guarantees β€” a case clause for nil that 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
  ...
end

The 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

@spec every public function in lib/. 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

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.

Browse all β†’