Dializer insisting on incorrect typespec, only when spec is explictly defined

I apologize for not reducing this to a simpler test case, but I can’t seem to reproduce this in any other situation. Note the create function is autogenerated based on a specification, hence the verbosity.

I have the following code (spanning a few files, but consolidated here):

#in Kurento.Client
@spec invoke(client(), any) :: {:error, map} | {:ok, nil | maybe_improper_list | map}
  @doc false
  def invoke(client, request) do
    name = via(client)

    GenServer.call(name, {:call, request})
  end

#In Kurento.Remote.MediaPipeline
@type t :: %{client: Kurento.Client.client(), id: String.t()}

  @spec create(any) :: {:error, Kurento.CallError.t()} | {:ok, Kurento.Remote.MediaPipeline.t()}
  def create(client) do
    constructor_params = %{}

    params = %{type: "MediaPipeline", constructorParams: constructor_params, properties: %{}}
    request = Kurento.RPCRequest.create("create", params)
    result = Kurento.Client.invoke(client, request)

    case(result) do
      {:ok, value} ->
        {:ok, Kurento.Remote.MediaPipeline.from_param(client, value["value"])}

      {:error, err} ->
        {:error, Kurento.CallError.from_map(err)}
    end
  end

  def test(client) do
    {:ok, pipe} = create(client)
    pipe
  end

Dialyzer does not like the {:ok, pipe} = create(client) line complaining:

lib/gen/remote/media_pipeline.ex:34:pattern_match
The pattern can never match the type.

Pattern:
{:ok, _pipe}

Type:
{:error, %Kurento.CallError{:code => integer(), :message => binary()}}

Basically insisting create will always return an error (it doesn’t).

What’s weird is if I remove the spec line @spec create(any) :: {:error, Kurento.CallError.t()} | {:ok, Kurento.Remote.MediaPipeline.t()}

Dialyzer is happy with that code, and Visual Studio Code is immediately suggesting I add it back (I assume inferred from Dialyzer).

Another oddball is if I add a new branch to the case (note that nil is impossible in this particular scenario, which is why the auto-generated code doesn’t have it):

{:ok, nil} ->
        :ok

The error becomes:

The pattern can never match the type.

Pattern:
{:ok, _pipe}

Type:
:ok | {:error, %Kurento.CallError{:code => integer(), :message => binary()}}

It seems (as long as the spec on create is in place) Dialyzer believes hitting the {:ok, value} branch is impossible. I tried to make it clear it was by adding the spec on `Kurento.Client.invoke, but I can’t seem to convince it.

If I remove the spec however, it infers the same spec, and is happy with it.

Does anyone have advice?

Definitely verify that something in that {:ok, value} branch isn’t failing to match inside from_param; Dialyzer will aggressively prune code paths that it doesn’t think can ever work.

BUT

I’ve seen similarly-weird issues occasionally with case structures, both on this forum and in production.

I suspect this may be related to with clause cannot match when case is inside else block · Issue #6738 · elixir-lang/elixir · GitHub which would explain why it’s so mysterious - the code is sensibly-shaped but Dialyzer is getting confused.

I managed to work this out (asking for help is the best way to ensure you find your dumb mistakes!) and it turns out I didn’t include the problematic piece of code in my example.

Any Kurento.Remote.* is a struct. Because the remote end makes significant use of inheritance, it’s often the case that a method will expect a MediaObject but need to accept something more specific like a Endpoint (of which there are also many kinds), which is what I was trying to allow for by defining the type t to be a map with just client and id (@type t :: %{...}), instead of a struct (@type t :: %Kurento.Remote.MediaPipeline{...}). But that type definition only allows the keys id and client. Because a struct is a map with a :__struct__ key, it means that a struct will never pass the typespec t.

In other words, Dialyzer didn’t like that Kurento.Remote.MediaPipeline.from_param(client, value["value"]) returns a struct. Instead of reporting the error there though, it seems like Dialyzer decided that branch was impossible and that create would always return an error. That’s frustrating, but coming from Haskell and TypeScript, I need to quit expecting so much of Dialyzer :slight_smile:.

I resolved it for now by changing the generator to create t so that it can match any struct (or a plain map):

@type t :: %{optional(:__struct__) => atom(), client: Kurento.Client.client(), id: String.t()}

At some point in the future I’ll work on typespec those to take inheritance into account (e.x. __struct__: => MediaObject | Dispatcher | Endpoint | ...) but this gets me back to being able to work on my project without a whole bunch of incorrect warnings.

Thanks!