Typespec matching any struct or map with a id key

Hi I got Dialyzer from is_session_creator function until I added %App.Account.User{id: integer} to typespec of second argument. Is there way to make type spec match any struct or map with id key? Or is there something wrong with my typespecs?

If you remove %App.Account.User{id: integer} from is_session_creator typespec you get this warning

The call 'Elixir.App.Workshop':'is_session_creator?'(_session@1::any(),#{'__meta__':=_, '__struct__':='Elixir.App.Account.User', 'disabled':=boolean(), 'email':=binary(), 'id':=integer(), 'inserted_at':=_, 'role':=binary(), 'updated_at':=_, 'verified':=boolean()}) breaks the contract ('nil' | #{'creator_id':=integer()},#{'id':=integer()} | integer()) -> boolean()

Code

  @spec is_session_creator?(
          nil | %{creator_id: integer},
          %App.Account.User{id: integer} | %{id: integer} | integer
        ) :: boolean
  def is_session_creator?(nil, _), do: false

  def is_session_creator?(session, %{id: user_id}) when is_integer(user_id) do
    is_session_creator?(session, user_id)
  end

  def is_session_creator?(%{creator_id: creator_id}, user_id)
      when is_integer(creator_id) and is_integer(user_id) do
    creator_id == user_id
  end

Here is User’s Ecto schema

defmodule App.Account.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :email, :string, null: false
    field :role, :string, null: false
    field :disabled, :boolean, null: false, default: false
    field :verified, :boolean, null: false, default: false

    timestamps()
  end

  @doc false
  def changeset(user, attrs) do
    user
    |> cast(attrs, [:email, :role, :disabled, :verified])
    |> validate_required([:email, :role, :disabled, :verified])
    |> unique_constraint(:email)
  end
end

This is the function that returned that User struct.

@spec get_current_user(Plug.Conn.t()) :: App.Account.User.t()
def get_current_user(conn), do: conn.assigns[:user]

Isn’t it complaining about the first argument you are passing to is_session_creator?/2? You specify the type for that should be nil | %{creator_id: integer}, yet the type (as far as dialyzer is concerned) of what you’re passing in appears to be any().

Make sure the result spec of what you’re passing in to is_session_creator?/2 agrees with what’s expected. This is the type of problem I believe you’re running into:

@spec foo(atom()) :: any()
def foo(:one), do: nil
def foo(:two), do: %{creator_id: 4}

@spec is_session_creator?(nil | %{creator_id: integer}) :: boolean()
def is_session_creator?(nil), do: false
def is_session_creator?(%{creator_id: id}) when is_integer(id), do: true

def test() do
  :two
  |> foo()
  |> is_session_creator?()
end

In the example above, although foo/1 will always return nil or a map, the spec says it can return anything. Then dialyzer looks at the specs and sees you’re trying to send “any type imaginable” to is_session_creator?/1 which definitely doesn’t accept every type. Therefore it raises an error.

As a rule of thumb, if you’re going to have “or types” representing the same “thing”, you’ll create fewer mistakes (and improve readability) if you declare a type for them. For example, if you declare a optional_session type to be nil | %{creator_id: integer}, all of a sudden your code will be clearer to the reader AND you’ll probably make fewer mistakes while writing the typespecs, because you’d use that same optional_session type as the return value of foo/1 AND is_session_creator?/1 in the example above. In other words, this will avoid mistakes of mismatched types.

Hope this helps!

2 Likes

No it’s not complaining about first argument. Without any code change when I add %App.Account.User{id: integer} to second argument’s typespec all Dialyzer errors disappear. I’m using Visual Studio Code in Windows with ElixirLS plugin version v0.2.25.

Also I have no idea why it’s showing any() for first argument. I have this code that returns a session

@spec cache_fetch_session_by_hex_id(binary) :: App.Workshop.Session.t() | nil
def cache_fetch_session_by_hex_id(session_id_string) do
  session_id_string
  |> id_from_hex_string()
  |> App.Workshop.cache_fetch_session()
end

Then I use it like this

session = cache_fetch_session_by_hex_id(session_id_str)

if App.Workshop.is_session_creator?(session, get_current_user(conn)) do
  participant_id = participant_id_str |> id_from_hex_string()
  App.Workshop.delete_session_participant?(session.id, participant_id)
  conn |> resp_empty_object()
else
  conn |> resp_forbidden_status()
end

Something must be wrong with my typespecs.

Can you please tell us how you actually call the function?

Also perhaps show us the output of dialyxir latest release candidates, it usually points out things better than Elixir-LS does.

Be also aware of the fact, that in some cases you need to shut down the LS, delete the .elixir_ls folder and start it again, to actually get its internal dialyzer DB in sync with the code again. This is the cost we pay for its speed…

1 Like

Can you try changing the 2nd function declaration to:

def is_session_creator?(%{creator_id: creator_id}, %{id: user_id}) when is_integer(creator_id) and is_integer(user_id) do

That could be where the any type comes from (because you’re not limiting the type of session with guards in your original code). Also, you should be able to remove %App.Account.User{id: integer} from the spec.

I found a solution, I needed to change my typespec to this

@spec is_session_creator?(
        %{required(:creator_id) => integer, optional(atom()) => any()} | nil,
        %{required(:id) => integer, optional(atom()) => any()} | integer
      ) :: boolean

Now it works with both maps and structs that have those fields. Thanks for your help!

3 Likes