From 4df8e0214d35d31c2e0b07f6c84cca5a47cdbae2 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Thu, 10 Sep 2026 13:16:08 +0200 Subject: [PATCH] Do not propagate value type refinement to original map Fixes #15882 --- lib/elixir/lib/module/types/expr.ex | 74 ++++++++++++++--- .../test/elixir/module/types/expr_test.exs | 81 +++++++++++++++++++ 2 files changed, 143 insertions(+), 12 deletions(-) diff --git a/lib/elixir/lib/module/types/expr.ex b/lib/elixir/lib/module/types/expr.ex index 33e8c6b686..dd4fe61523 100644 --- a/lib/elixir/lib/module/types/expr.ex +++ b/lib/elixir/lib/module/types/expr.ex @@ -181,18 +181,19 @@ defmodule Module.Types.Expr do {{key_type, value_type}, context} end) - # The only information we can attach to the expected types is that - # certain keys are expected. And we can only do so if the key has - # no other components, even dynamic ones (hence upper bound). - expected_pairs = - Enum.flat_map(pairs_types, fn {key_type, _value_type} -> - case atom_fetch(upper_bound(key_type)) do - {:finite, [key]} -> [{key, {term(), false}}] - _ -> [] - end - end) - - expected = opt_intersection(expected, open_map(expected_pairs)) + # The expected type describes the result of the update, not the + # original map. Fields that are replaced by the update need to exist + # in the original map, but their old values are not constrained by + # what the consumer expects of the new values. Fields that are not + # replaced keep whatever constraint the consumer has on them. + # + # A key that is a single atom must exist in the original map. A key + # that is a finite set of atoms may replace any of those fields, so + # their constraints are erased without requiring them to exist. Any + # other key (infinite atoms or non-atom domains) may replace any field, + # so all value constraints are dropped and only the statically known + # keys are kept as required. + expected = map_update_expected(pairs_types, expected) {map_type, context} = of_expr(map, expected, expr, stack, context) try do @@ -927,6 +928,55 @@ defmodule Module.Types.Expr do ## General helpers + # Computes the expected type of the map being updated in %{map | ...} + # from the expected type of the update result. See the map update + # clause in of_expr/5 for the rationale. + defp map_update_expected(pairs_types, expected) do + keys = + Enum.reduce_while(pairs_types, [], fn {key_type, _value_type}, acc -> + case atom_fetch(upper_bound(key_type)) do + {:finite, [key]} -> {:cont, [{:required, key} | acc]} + {:finite, keys} -> {:cont, [{:optional, keys} | acc]} + _ -> {:halt, :unknown} + end + end) + + with keys when keys != :unknown <- keys, + {:ok, result} <- erase_updated_keys(keys, opt_intersection(expected, open_map())) do + result + else + _ -> open_map(map_update_required_keys(pairs_types)) + end + end + + defp erase_updated_keys(keys, acc) do + Enum.reduce_while(keys, {:ok, acc}, fn + {:required, key}, {:ok, acc} -> + # term() erases the constraint but still requires the key to exist + case map_put_key(acc, key, term()) do + {:ok, acc} -> {:cont, {:ok, acc}} + :badmap -> {:halt, :badmap} + end + + {:optional, keys}, {:ok, acc} -> + # Any of the keys may be replaced, so erase all of them without + # asserting the existence of any particular one + case map_update(acc, atom(keys), term(), true, false, true) do + {_type, acc, _errors} -> {:cont, {:ok, acc}} + _ -> {:halt, :badmap} + end + end) + end + + defp map_update_required_keys(pairs_types) do + Enum.flat_map(pairs_types, fn {key_type, _value_type} -> + case atom_fetch(upper_bound(key_type)) do + {:finite, [key]} -> [{key, {term(), false}}] + _ -> [] + end + end) + end + defp apply_many([], fun, args, expected, expr, stack, context) do Apply.remote(fun, args, expected, expr, stack, context, &of_expr/5) end diff --git a/lib/elixir/test/elixir/module/types/expr_test.exs b/lib/elixir/test/elixir/module/types/expr_test.exs index 20a94a39ca..7ac0de2f5f 100644 --- a/lib/elixir/test/elixir/module/types/expr_test.exs +++ b/lib/elixir/test/elixir/module/types/expr_test.exs @@ -1396,6 +1396,87 @@ defmodule Module.Types.ExprTest do """ end + test "updating maps does not constrain the replaced values" do + # The replaced key must exist but its old value is unconstrained + assert typecheck!( + [x], + ( + %{a: 1} = %{x | a: 1} + x + ) + ) == + dynamic(open_map(a: {term(), false})) + + assert typecheck!( + [x], + ( + %{x | a: 1}.a + 1 + x + ) + ) == + dynamic(open_map(a: {term(), false})) + + assert typecheck!( + [x], + ( + %{a: 1, b: 2} = %{x | a: 1, b: 2} + x + ) + ) == + dynamic(open_map(a: {term(), false}, b: {term(), false})) + + # Constraints on fields that are not replaced still propagate + assert typecheck!( + [x], + ( + %{a: 1, b: 2} = %{x | a: 1} + x + ) + ) == + dynamic(open_map(a: {term(), false}, b: {integer(), false})) + + assert typecheck!( + [x], + ( + %{x | a: 1}.b + 1 + x + ) + ) == + dynamic(open_map(a: {term(), false}, b: {opt_union(integer(), float()), false})) + + # Finite computed keys erase only their fields and require none of them + assert typecheck!( + [x], + ( + key = if :rand.uniform() > 0.5, do: :a, else: :b + %{a: 1, c: 2} = %{x | key => 1} + x + ) + ) + |> equal?(dynamic(open_map(c: {integer(), false}))) + + assert typecheck!( + [x], + ( + key = if :rand.uniform() > 0.5, do: :a, else: :b + %{x | key => 1, c: 2}.c + 1 + x + ) + ) + |> equal?(dynamic(open_map(c: {term(), false}))) + + # Unbounded computed keys may replace any field, so no value constraint propagates + assert typecheck!( + [x, key], + is_atom(key), + ( + %{a: 1, b: 2} = %{x | key => 1} + x + ) + ) == + dynamic(open_map()) + end + test "updating maps with mixed record/dictionary keys" do # Static keys assert typecheck!(