Skip to content

Fix map domain update - #15953

Open
gldubc wants to merge 5 commits into
elixir-lang:mainfrom
gldubc:fix-map-domain-update
Open

gldubc wants to merge 5 commits into
elixir-lang:mainfrom
gldubc:fix-map-domain-update

Conversation

@gldubc

@gldubc gldubc commented Sep 29, 2026

Copy link
Copy Markdown
Member

Closes #15482, #15509, #15594, #15688 and #15687. Supersedes PR #15510.

This makes map key/domain operations:

  • correctly project negative constraints through updates. Both for named keys AND for domain keys
  • ignore empty lines

We now distinguish, in the map update operation, between constant replacements (Map.put for instance) and callback ones, that require the previous value to be modified into a new type. The algorithm for constants is faster.

This also adds the following optimization: when building a map leaf, a quick check is done to see if the map is definitely non_empty, or if it could maybe be non-empty. This is stored in the lowest bit of the hash, and is used for instant map emptiness checking for leaves.

Bugfix: unfold term() keys before splitting explicit keys from domains.

Regression coverage in this PR:

Assisted-by: GPT-6-Astra

Keep direct domain lookups and the simplified domain helper from main,
retain projected-pair updates and the map emptiness cache, and preserve
the force? documentation alongside the explicit missing-key policy.

Adjust the mixed-key map-update regression to retain nonempty results
while leaving the original map unrefined.
@lukaszsamson

Copy link
Copy Markdown
Contributor

@gldubc I checked the PR and #15594 is only partially addressed. The simple case from comment #15594 (comment) is still wrong.

Besides that, the PR introduces two regressions:

  1. Callback unions lose successful results. This affects Map.update!/3, Map.update/4, and Map.replace_lazy/3

Repro:

defmodule CallbackUnion do
  def go(flag) do
    {value, fun} =
      if flag do
        {1.0, fn
          x when is_integer(x) -> :integer_branch
          x when is_float(x) -> :float_branch
        end}
      else
        {1, fn x when is_integer(x) -> :other_branch end}
      end

    result = Map.update!(%{a: value}, :a, fun)

    case result do
      %{a: :float_branch} -> :valid
      _ -> :other
    end
  end
end

{CallbackUnion.go(true), CallbackUnion.go(false)}
# => {:valid, :other}

emits a false positive warning:

    warning: the following clause will never match:

        %{a: :float_branch} ->

    because it attempts to match on the result of:

        result

    which has type:

        dynamic(%{a: :integer_branch or :other_branch})

    type warning found at:
    │
 16 │       %{a: :float_branch} -> :valid
    │       ~
    │
    └─ callback_union.ex:16:7: CallbackUnion.go/1

On main the code compiles cleanly.

Reason: fun_apply_or_none computes the application domain as a union of function alternatives. Then lower_bound(domain) is intersected with the argument. This is not sound with gradual types.

Another example with disjoint domains:

defmodule EmptyDomain do
  def run(flag) do
    {value, fun} =
      if flag do
        {1.0, fn x when is_float(x) -> :float_branch end}
      else
        {1, fn x when is_integer(x) -> :integer_branch end}
      end

    Map.update!(%{a: value}, :a, fun)
  end
end

emits a false positive:

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

    Map.update!(%{a: value}, :a, fun)

given types:

    -dynamic(float() or integer())-

but function has type:

    dynamic((float() -> :float_branch) or (integer() -> :integer_branch))

hint: the function has an empty domain and therefore cannot be applied to any argument
  1. Compile time blowup with chained dynamic key map puts

The new map_update_domains/map_rejoin_domain build one alternative per key domain, and with a dynamic() key that is 12 to 13 domains

Repro:
compile time of this module on my machine on
main: 0.3s
PR: 50s

defmodule Blowup do
  def build(k1, k2, k3, k4, k5) do
    Map.new()
    |> Map.put(k1, 1) |> Map.put(k2, 2) |> Map.put(k3, 3)
    |> Map.put(k4, 4) |> Map.put(k5, 5)
  end
end

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Return type of Map.pop is too narrow

2 participants