Skip to content

Possible false-positive type warning with Map.update/4 for a struct field #15687

Description

@abravalheri

Existing issue

  • I have searched existing issues and could not find a duplicate.

I did notice that issue #15625 seems a bit similar, but it was pointed out there that the inference does not work with string keys, which is not exactly the case here.

Elixir and Erlang/OTP versions

% elixir -v
Erlang/OTP 29 [erts-17.0.3] [source] [64-bit] [smp:16:16] [ds:16:16:10] [async-threads:1] [jit:ns]

Elixir 1.20.2 (compiled with Erlang/OTP 27)

Operating system

Ubuntu 24.04.4 LTS [Windows 11 + WSL 2.7.11.0 (Kernel version: 6.18.33.2-2)]

Current behavior

First of all, thank you for all the work on the recent compiler improvements and incremental type system. The new features are very useful (and helpful to catch bugs).

I was working on a private project and I noticed what to me looks like a surprising false positive in the form of a warning.

The original code comes from a much larger codebase and looks quite different (and the call to Map.update is in the middle of a much larger pipeline), but I have tried to simplify it down to the smallest example that still triggers the same warning:

# reproducer.ex
defmodule Reproducer do

  defstruct users: %{},
            groups: %{}

  @type t :: %__MODULE__{
          users: %{String.t() => map()},
          groups: %{String.t() => map()}
        }

  @field %{
    user: :users,
    group: :groups
  }

  @spec put(t(), :user | :group, String.t(), map()) :: t()
  def put(%__MODULE__{} = state, kind, id, %{} = value)
      when is_map_key(@field, kind) and is_binary(id) do
    state
    |> Map.update(@field[kind], %{id => value}, &Map.put(&1, id, value))
  end

  @spec put2(t(), :user | :group, String.t(), map()) :: t()
  def put2(%__MODULE__{} = state, kind, id, %{} = value)
      when kind in [:user, :group] and is_binary(id) do
    state
    |> Map.update(@field[kind], %{id => value}, &Map.put(&1, id, value))
  end

  @spec put_user_no_warning(t(), String.t(), map()) :: t()
  def put_user_no_warning(%__MODULE__{} = state, id, %{} = value)
      when is_binary(id) do
    state
    |> Map.update(:users, %{id => value}, &Map.put(&1, id, value))
  end

  @spec put_user_non_struct_field(t(), String.t(), map()) :: t()
  def put_user_non_struct_field(%__MODULE__{} = state, id, %{} = value)
      when is_binary(id) do
    state
    |> Map.update(:user, %{id => value}, &Map.put(&1, id, value))
  end
end
% rm -rf Elixir.Reproducer.beam
% elixirc reproducer.ex
    warning: incompatible types given on function call within Map.update/4:

        Map.update(state, %{group: :groups, user: :users}[kind], %{id => value}, &Map.put(&1, id, value))

    given types:

        dynamic(Reproducer)

    but function has type:

        (map() -> dynamic(map()))

    where "id" was given the types:

        # type: binary()
        # from: reproducer.ex:17:40
        id

        # type: binary()
        # from: reproducer.ex:18:41
        is_binary(id)

    where "kind" was given the type:

        # type: dynamic()
        # from: reproducer.ex:17:34
        kind

    where "state" was given the type:

        # type: dynamic(%Reproducer{})
        # from: reproducer.ex:17:25
        %Reproducer{} = state

    where "value" was given the type:

        # type: dynamic(map())
        # from: reproducer.ex:17:48
        %{} = value

    type warning found at:
    │
 20 │     |> Map.update(@field[kind], %{id => value}, &Map.put(&1, id, value))
    │            ~
    │
    └─ reproducer.ex:20:12: Reproducer.put/4

    warning: incompatible types given on function call within Map.update/4:

        Map.update(state, %{group: :groups, user: :users}[kind], %{id => value}, &Map.put(&1, id, value))

    given types:

        dynamic(Reproducer)

    but function has type:

        (map() -> dynamic(map()))

    where "id" was given the types:

        # type: binary()
        # from: reproducer.ex:24:41
        id

        # type: binary()
        # from: reproducer.ex:25:40
        is_binary(id)

    where "kind" was given the types:

        # type: dynamic(:group or :user)
        # from: reproducer.ex:24:35
        kind

        # type: :group or :user
        # from: reproducer.ex:25
        kind in [:user, :group]

    where "state" was given the type:

        # type: dynamic(%Reproducer{})
        # from: reproducer.ex:24:26
        %Reproducer{} = state

    where "value" was given the type:

        # type: dynamic(map())
        # from: reproducer.ex:24:49
        %{} = value

    type warning found at:
    │
 27 │     |> Map.update(@field[kind], %{id => value}, &Map.put(&1, id, value))
    │            ~
    │
    └─ reproducer.ex:27:12: Reproducer.put2/4

Warnings are emitted for:

put/4
put2/4

In both cases, the compiler seems to be able to correctly infer the type of the argument kind as :group | :user (either via the direct in clause or via is_map_key with the module attribute).

Yet the warning is still emitted, and it was not immediately clear to me what type mismatch the diagnostic is pointing to. That is why I have also included in the reproducer 2 other functions:

put_user_no_warning/3
put_user_non_struct_field/3

Which are very similar to put and put2 but use a literal atom instead of a lookup through @field.

So it seems that somehow the error may be related to the typing of the last fn argument passed to Map.update/4?

Interestingly put_user_non_struct_field uses a field that doesn't belong in the struct (user vs users), but there is no warning produced (I am not claiming there should be one, but it is interesting to see that in this case the warning is not triggered for the same kind of fn argument passed to Map.update/4).

What is surprising to me is that I cannot identify anything obviously wrong with the example. The compiler appears to infer most of the relevant information correctly during compilation, yet the final Map.update/4 still triggers a warning. To my eyes, it feels as if enough information is available to establish type correctness, but some of it may be getting lost before the callback argument is analysed.


Would this be considered:

  • A false-positive warning?
  • An intentional limitation of the current analysis?
  • Expected behaviour for this pattern?

I fully appreciate that this kind of static/type analysis is necessarily conservative, and I do not expect it to infer every valid program. False positives maybe inevitable in a flexible language.

That said, is there currently a way to suppress a specific compiler warning locally without disabling the warnings all together? (they are very useful in general and it would be a pity to loose some overall static analysis info).

Thank you again for all the work on the compiler and type system. Even if this ends up being an expected limitation, I would appreciate guidance on the preferred approach for handling this class of warning.

Expected behavior

Either no warning (similar behaviour to put_user_no_warning/3), if this is considered a false positive, or a mechanism to suppress this specific warning when the programmer intentionally relies on a safe behaviour that is not currently inferred by the type analysis.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions