This story was originally posted by me on Medium on 2019-09-03, but moved here since I'm closing my Medium account.
I like dialyzer and typespecs. They are a natural part of my TDD flow:
- Write a failing test
- Write docs for function
- Write typespecs for function
- Write the function
- See that test is now green
- See that test is still green
Having written tests, docs and a spec for a function, makes it much more clear what the function should accept as arguments and what it should return. I also tend to write more reusable functions with this flow.
The typespecs will also function as documentation when reading the function.
But dialyzer has a downside. Debugging dialyzer warnings can be a real pain and it is often not clear where to look for a wrong typespec or whatever made dialyzer vomit all over my terminal with less-than-obvious messages.
I spend a good three hours today debugging a dialyzer warning in my Elixir app, so I’m naturally obligated to share my insights with the world :)
I got an error in a with statement much like this one in function
defmodule DialyzerTest do @spec a() :: :ok | :error def a do [:ok, :error] |> Enum.random() end @spec b() :: :ok | :another_error def b do [:ok, :another_error] |> Enum.random() end @spec c() :: :ok | :error def c do with :ok <- a(), :ok <- b() do :ok else :error -> :error end end end
The dializer warning says:
simple_dialyzer_test.ex:14:pattern_match The pattern can never match the type. Pattern: :error Type: :another_error
This is a pretty light-weight warning compared to dialyzer standards. Normally I need to copy-paste the message into an editor to format it and be able to make a little sense out of it.
:error does not match
:another_error, which is obvious, but why complain about it? When I wrote the code, I called
b/0 in a way that shouldn’t make it return anything else than
:ok, so I would like a runtime exception if anything else is returned from that function.
My mistake was that I considered the above example the same as this one:
defmodule DialyzerTest do @spec a() :: :ok | :error | :another_error def a do [:ok, :error, :another_error] |> Enum.random() end @spec c() :: :ok | :error def c do with :ok <- a() do :ok else :error -> :error end end end
Notice that we only match on
:error, but not on
:another_error. This code does not produce a dialyzer warning.
What the hell is going on?
After a long time of debugging and trying to find a wrong typespec, I realized how dialyzer handles with: For each statement in the
with (all the lines with
a <- b), we can think of it as “
b must either match
a or must match at least one clause in
I tried digging deeper in the elixir source code to get a confirmation of this, but
with is a special form and implemented in dark magic, so I got no further.
Yes, there is an easy solution. If you don’t want the statement in the
with to match anything in
else, don’t include the statement in the
with. You can include it before, after or inside
do, whateever fits the use case.
In my small example from the top, I would simply rewrite, so that the call to
b/0 was moved inside the
do and matched with the expected return value:
@spec c() :: :ok | :error def c do with :ok <- a() do :ok = b() :ok else :error -> :error end end
And since I now only have a single statement in the
with, I would probably refactor to a
@spec c() :: :ok | :error def c do case a() do :ok -> :ok = b() :ok :error -> :error end end
Again, dialyzer was right. It does not make sense to have a statement inside a
with if I’m never going to match a clause in
else with it. My code is now easier to read, since it is obvious to the reader that
b/0 must return