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
- Refactor
- 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 :)
The setup
I got an error in a with statement much like this one in function c/0
:
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.
So apparently :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 :ok
and :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 else
”.
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.
Solution?
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 case
:
@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 :ok
.
Top comments (1)
How does final
b/0
look like?