What’s going on here?

OK. As it usually happens with dialyzer… I had many questions, but I knew…

Dialyzer is NEVER wrong

So… Let’s see if we can figure this out because, as Sean so brilliantly expressed on his latest talk at CodeBeamSF, it must be just a little misunderstanding between me and dialyzer.

What dialyzer says

So, let’s do the obvious thing first… dialyzer says that my call to MyODT.f1/1 doesn’t have a proper MyODT.t argument. What I am using as an argument to that function is odt , a variable that, according to the spec I wrote for MyODTUser.print_odt is actually an instance of MyODT.t 🤔

Dialyzer also says that MyODTUser.print_odt will never return, but that’s likely because it’s considering the other discrepancy. If I fix that one, I’ll remove both of them at once.

What dialyzer MEANS

If you check Stavros talk (video below) you’ll learn that dialyzer works by inferring the broader possible type for each variable and emitting warnings when it can’t infer any possible type for one.

With that in mind, and since it’s complaining about odt , let’s try to figure out what dialyzer has inferred as its success type.

Actually, we don’t have to go too far for that. It’s in the warning itself: Vodt@1::#{'f2':=_,_=>_} . As you might have noticed Vodt@1 is just the Erlang representation for the variable odt and #{'f2':=_,_=>_} is its type.

That map is somewhat similar to our opaque type MyODT.t , but not quite… since it allows maps to have any keys and values, as long as they have a field called f2 and MyODT.t only allows f1 and f2 as keys (and both of them are required).

How could dialyzer found such a type for odt then? Well… let’s try to see what information was available when it was inferring the types.

There is a typespec for print_odt/1 , but dialyzer only uses typespecs to narrow down the success types once they’re found. Which is not this case, so… that spec wasn’t on dialyzer’s mind at the time of the warning.

The only other info available was the fact that odt was used to call both MyODT.f1/1 and MyODT.f2/1 . And that’s the key to solve our mystery! Because if you check the code for that module, you will notice that MyODT.f1/1 has a spec, but MyODT.f2/1 hasn’t.

Not having a spec for MyODT.f2/1 , dialyzer does its best and figures out the type of odt has to be the success typing of the argument of that function (i.e. #{'f2':=_,_=>_} ). And that type is not opaque. Since there is no spec that says so, there is no way for dialyzer to tell that f2 actually requires an instance of MyODT.t and not any map with an f2 key.

Then, when dialyzer tries to match that type against the success typing of the argument of MyODT.f1/1 (i.e. MyODT.t )… 💥 … There is no way to match a random map type against that opaque type. As a matter of fact, the only type that matches with an opaque type is that same opaque type. That’s the whole point. Even if you manage to build something that looks like the definition of the opaque type if dialyzer can’t prove that it is, in fact, the expected opaque type it will emit a warning. In other words: we are violating the opaqueness of that argument.

Simply adding a spec to MyODT.f2/1 removes both warnings. And that leads us again to the lesson of the day:

If you define an opaque type, you have to add specs to all the exported functions that use it (i.e. your module’s API).

What I would LIKE dialyzer to say

One day, someone will finish what Elli Fragkaki once started and dialyzer will tell us something along the lines of…

lib/dialyzer_example.ex:19: Function print_odt/1 has no local return

lib/dialyzer_example.ex:19: The call to 'Elixir.MyODT':f1/1 requires an opaque term of type 'Elixir.MyODT':t() as 1st argument and the variable that you're using for it (Vodt@1) must have type #{'f2':=_, _=>_} since it's also used in a call to 'Elixir.MyODT':f2/1

…or something even clearer and more helpful!