Typespec ignoring second return type

I have code that maps incoming data (as a map()) onto a struct:

  @spec struct!(opts :: map() | nil) :: t() | nil
  def struct!(nil), do: nil

  def struct!(opts) when is_map(opts),
    do: %__MODULE__{
      # Omitted for brevity
    }

When I try to use it however:

case Guild.struct!(event) do
  nil ->
    nil

  guild ->
    print_guild(guild)
end

I get a warning on the second branch:

The variable _guild@1 can never match since previous clauses completely covered the type ‘nil’

For some reason, the typespec only detects the nil and ignores that it can also return t() (which is a typespec for the struct itself)

1 Like

I believe you’re getting a Dialyzer warning on the second branch because Dialyzer assumes that event that you’re passing to Guild.struct! must be nil for some reason. You should check your code and typespecs that lead to constructing event before this point.

I tried to reproduce with a small example and assigning event to be nil explicitly and I didn’t get the same result so this may be wrong. I might be able to help if you shared more of the code though, if you’re willing to do that.

It’s coming in from a handle_cast which I annotated with a @impl GenServer
I could try passing in a hardcoded non-nil value and see what that gives.

This is a guess, it could be a different issue, but I managed to reproduce a similar Dialyzer warning with this small example:

defmodule Guild do
  @type t() :: %__MODULE__{one: String.t(), two: list()}
  defstruct one: nil,
            two: []

  @spec struct!(opts :: map() | nil) :: t() | nil
  def struct!(nil), do: nil

  def struct!(opts) when is_map(opts),
    do: %__MODULE__{
      two: opts.two
    }
end

defmodule Hmm do
  def hello do
    event = %{two: [1]}

    case Guild.struct!(event) do
      nil ->
        nil

      guild ->
        IO.inspect(guild)
    end
  end
end

The issue here is that the second Guild.struct! clause does not return a valid t() (notice that t() defines one to be String.t() but in that case it will be nil). The fix is to update the type specification for t() or introduce a separate type that correctly captures the returned struct. edit: Or to make sure that you return the struct in a valid shape, with each field of the type that the t() specifies.

This is a common issue and Dialyzer can (sometimes) hint at the solution in a better way if you run it with the -Woverspecs. With ElixirLS VSCode extension it would mean configuring it with "elixirLS.dialyzerWarnOpts": ["overspecs"]. With my example above, it would show this warning at the Guild.struct! spec definition:

The type specification is missing types returned by function.

Function:
Guild.struct!/1

Type specification return types:
nil | %Guild{:one => binary(), :two => [any()]}

Missing types:
(%Guild{:one => nil, :two => _})

Hey thanks for the swift reply

I’ll give this a shot when I’m at the computer again tonight!

I don’t understand entirely what’s going wrong I’m afraid.
Given the following code:

  @type t :: %__MODULE__{
          large_image?: String.t() | nil,
          large_text?: String.t() | nil,
          small_image?: String.t() | nil,
          small_text?: String.t() | nil
        }

  defstruct [
    :large_image?,
    :large_text?,
    :small_image?,
    :small_text?
  ]

  @spec struct!(opts :: map() | nil) :: t() | nil
  def struct!(nil), do: nil

  def struct!(opts) when is_map(opts),
    do: %__MODULE__{
      large_image?: Map.get(opts, "large_image", nil),
      large_text?: Map.get(opts, "large_text", nil),
      small_image?: Map.get(opts, "small_image", nil),
      small_text?: Map.get(opts, "small_text", nil)
    }

When I enable overspec, I get the warning

Type specification 'Elixir.Wumpex.Discord.Activity.Assets':'struct!'(opts::map() | 'nil') -> t() | 'nil' is a subtype of the success typing: 'Elixir.Wumpex.Discord.Activity.Assets':'struct!'
          ('nil' | map()) ->
             'nil' |
             #{'__struct__' := 'Elixir.Wumpex.Discord.Activity.Assets',
               'large_image?' := _,
               'large_text?' := _,
               'small_image?' := _,
               'small_text?' := _}

Though I don’t see what’s missing/wrong?
All fields are defined to be either nil or a string, which is what Map.get/2 would give me, no?

EDIT:
if I change the typespec from String.t() to any() the warning dissapears, is it because it cannot validate the return type from Map.get/2?

The original warning disappeared though, right? You can disable overspecs now then.

The warning you’re seeing now is because Map.get/2 has a spec with any(). Notice the difference between the one I said was a hint and that one, previously it said there is a missing type with one of the fields as nil and now you’re seeing a different warning with _ (I think in this case meaning any()? not sure why) at each field. This is the reason overspecs is disabled by default - sometimes it can be helpful, but more often it introduces noise.