Eqwalizer complains about union types in arguments

In one of our projects, we’ve got some code that converts (TagName, Value) to {TagIndex, EncodedValue}. It looks like this (simplified):

encode_tagged_field(_Key = cluster_id, ClusterId) ->
    {0, encode_string(ClusterId)};
encode_tagged_field(_Key = replica_state, ReplicaState) ->
    {1, encode_int32(ReplicaState)};
encode_tagged_field(_Key, _Value) ->
    ignore.

That is: the type of the value depends on the key, and the function called accepts that type. I’ve added a spec that looks like this:

-spec encode_tagged_field(
    Key :: atom(), Value :: binary() | integer()
) -> {non_neg_integer(), iodata()} | ignore.

But eqwalizer complains about it, because each function clause calls a function (e.g. encode_int32) that only accepts one of the possible types in the overall argument type union.

How do I express to eqwalizer’s satisfaction that each clause only applies to one of the types in the union?

If I write the -spec like this…

-spec encode_tagged_field
    (cluster_id, Value :: binary() | null) -> {0, iodata()};
    (replica_state, Value :: integer()) -> {1, iodata()}.

…then eqwalizer is happy.

But the spec is missing the catch-all clause (that returns ignore). If I add that…

-spec encode_fetch_request_15_tagged_field
    (cluster_id, Value :: binary() | null) -> {0, iodata()};
    (replica_state, Value :: integer()) -> {1, iodata()};
    (Key :: atom(), Value :: any()) -> ignore.

…eqwalizer becomes unhappy again.

There is no way to tell eqWAlizer that each clause accepts one type of the union, except by using an overloaded spec discriminating on the first argument, as you did.

And as you’ve seen, support for overloaded specs in eqWAlizer is best-effort. It supports sub-specs that do not overlap and are easily discriminated (here with an atom), but unfortunately, is breaks down when there is a overlap as in your last solution.

You can use eqwalizer:dynamic/1 from the latest version of eqwalizer.erl to make type-checking a bit less restrictive here:

-spec encode_tagged_field(
    Key :: atom(), Value :: eqwalizer:dynamic(binary() | integer())
) -> {non_neg_integer(), iodata()} | ignore.

basically, this makes it so you can use Value as if it were dynamic(), but will still type-check calls to encode_tagged_field/2 correctly.

Or you can use dynamic/0 to relax the last spec:

-spec encode_fetch_request_15_tagged_field
    (cluster_id, Value :: binary() | null) -> {0, iodata()};
    (replica_state, Value :: integer()) -> {1, iodata()};
    (Key :: atom(), Value :: dynamic()) -> ignore | dynamic().

although that may get too verbose.