From a530ca6fe34e8c873ce5063c68a9f575920799fe Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Thu, 14 May 2026 15:55:30 +0200 Subject: [PATCH 1/3] Type system support for put_elem/3 (:erlang.setelement/3) --- lib/elixir/lib/module/types/apply.ex | 28 ++++++ lib/elixir/lib/module/types/descr.ex | 89 ++++++++++++++++++ .../test/elixir/module/types/descr_test.exs | 92 +++++++++++++++++++ .../test/elixir/module/types/expr_test.exs | 71 ++++++++++++++ 4 files changed, 280 insertions(+) diff --git a/lib/elixir/lib/module/types/apply.ex b/lib/elixir/lib/module/types/apply.ex index 9dc295f87d..d0e8d4262b 100644 --- a/lib/elixir/lib/module/types/apply.ex +++ b/lib/elixir/lib/module/types/apply.ex @@ -487,6 +487,34 @@ defmodule Module.Types.Apply do end end + defp do_remote(:erlang, :setelement, [index, tuple, elem], _, expr, stack, context, of_fun) + when is_integer(index) and index < 1 do + {_, context} = of_fun.(tuple, open_tuple([]), expr, stack, context) + {_, context} = of_fun.(elem, term(), expr, stack, context) + remote_error({:negindex, index - 1}, :erlang, :setelement, 3, expr, stack, context) + end + + defp do_remote(:erlang, :setelement, [index, tuple, elem], _, expr, stack, context, of_fun) + when is_integer(index) do + tuple_type = open_tuple(List.duplicate(term(), max(index, 1))) + + {tuple_type, context} = of_fun.(tuple, tuple_type, expr, stack, context) + {elem_type, context} = of_fun.(elem, term(), expr, stack, context) + + case tuple_replace_at(tuple_type, index - 1, elem_type) do + value_type when is_descr(value_type) -> + {return(value_type, [tuple_type, elem_type], stack), context} + + :badtuple -> + args_types = [integer(), tuple_type, elem_type] + remote_error(:erlang, :setelement, args_types, expr, stack, context) + + :badindex -> + error = {:badindex, index, tuple_type} + remote_error(error, :erlang, :setelement, 3, expr, stack, context) + end + end + defp do_remote(:erlang, :delete_element, [index, tuple], _, expr, stack, context, of_fun) when is_integer(index) do tuple_type = open_tuple(List.duplicate(term(), max(index, 1))) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index 9ad6f166c3..be980e1465 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -5925,6 +5925,95 @@ defmodule Module.Types.Descr do end) end + @doc """ + Replace an element in the tuple at the given (0-based) index. + + It returns the same as `tuple_fetch/2`. + """ + def tuple_replace_at(:term, _key, _type), do: :badtuple + + def tuple_replace_at(descr, index, type) when is_integer(index) and index >= 0 do + case :maps.take(:dynamic, unfold(type)) do + :error -> + tuple_replace_at_checked(descr, index, type) + + {dynamic_type, _static} -> + case tuple_replace_at_checked(descr, index, dynamic_type) do + atom when atom in [:badtuple, :badindex] -> atom + result -> dynamic(result) + end + end + end + + def tuple_replace_at(_, _, _), do: :badindex + + defp tuple_replace_at_checked(descr, index, type) do + case :maps.take(:dynamic, descr) do + :error -> + # Note: the empty type is not a valid input + is_proper_tuple? = descr_key?(descr, :tuple) and non_empty_tuple_only?(descr) + is_proper_size? = tuple_of_size_at_least_static?(descr, index + 1) + + cond do + is_proper_tuple? and is_proper_size? -> tuple_replace_static(descr, index, type) + is_proper_tuple? -> :badindex + true -> :badtuple + end + + {dynamic, static} -> + is_proper_tuple? = descr_key?(dynamic, :tuple) and tuple_only?(static) + is_proper_size? = tuple_of_size_at_least_static?(static, index + 1) + + cond do + is_proper_tuple? and is_proper_size? -> + static_result = tuple_replace_static(static, index, type) + + # Prune for dynamic values that make the operation succeed. + dynamic_input = intersection(dynamic, tuple_of_size_at_least(index + 1)) + + if empty?(dynamic_input) and empty?(static) do + :badindex + else + dynamic_result = tuple_replace_static(dynamic_input, index, type) + union(dynamic(dynamic_result), static_result) + end + + # Highlight the case where the issue is an index out of range from the tuple + is_proper_tuple? -> + :badindex + + true -> + :badtuple + end + end + end + + defp tuple_replace_static(descr, _, _) when descr == @none, do: none() + + # Unlike insert/delete, replace is not injective at the replaced index, so + # transformed negative leaves would discard valid outputs. We eliminate + # negations first by converting to a positive-only DNF and replacing in + # each disjunct. + defp tuple_replace_static(descr, index, type) do + Map.update!(descr, :tuple, fn bdd -> + tuple_bdd_to_dnf_no_negations(bdd) + |> Enum.reduce(:bdd_bot, fn {tag, elements}, acc -> + # If the tuple is open, then we want List.replace_at to update the correct + # index, which requires filling the tuple with `term()` values first. + # Closed tuples of an incorrect size will have been cancelled by the + # earlier intersection with `tuple_of_size_at_least`. + elements = + if tag == :open and length(elements) < index + 1 do + tuple_fill(elements, index + 1) + else + elements + end + + bdd_union(acc, tuple_new(tag, List.replace_at(elements, index, type))) + end) + end) + end + defp tuple_of_size_at_least(n) when is_integer(n) and n >= 0 do %{tuple: tuple_new(:open, List.duplicate(term(), n))} end diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index d7e3357d14..474efd9c10 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -1887,6 +1887,98 @@ defmodule Module.Types.DescrTest do assert tuple_insert_at(tuple([atom([:ok])]), 2, dynamic()) == :badindex end + test "tuple_replace_at" do + assert tuple_replace_at(tuple([integer(), atom()]), 2, boolean()) == :badindex + assert tuple_replace_at(tuple([integer(), atom()]), -1, boolean()) == :badindex + assert tuple_replace_at(empty_tuple(), 0, boolean()) == :badindex + assert tuple_replace_at(integer(), 0, boolean()) == :badtuple + assert tuple_replace_at(term(), 0, boolean()) == :badtuple + assert tuple_replace_at(tuple([none()]), 0, boolean()) == :badtuple + + # Out-of-bounds in a union + assert union(tuple([integer(), atom()]), tuple([float()])) + |> tuple_replace_at(1, boolean()) == :badindex + + # Test replacing an element in a closed tuple + assert tuple_replace_at(tuple([integer(), atom(), boolean()]), 1, float()) == + tuple([integer(), float(), boolean()]) + + # Test replacing the first element of a closed tuple + assert tuple_replace_at(tuple([integer(), atom()]), 0, boolean()) == + tuple([boolean(), atom()]) + + # Test replacing the last element of a closed tuple + assert tuple_replace_at(tuple([integer(), atom()]), 1, boolean()) == + tuple([integer(), boolean()]) + + # Test replacing in an open tuple + assert tuple_replace_at(open_tuple([integer(), atom(), boolean()]), 1, float()) == + open_tuple([integer(), float(), boolean()]) + + # Test replacing with a dynamic type + assert tuple_replace_at(tuple([integer(), atom()]), 1, dynamic()) == + dynamic(tuple([integer(), term()])) + + # Test replacing in a dynamic tuple + assert tuple_replace_at(dynamic(tuple([integer(), atom()])), 1, boolean()) == + dynamic(tuple([integer(), boolean()])) + + # Test replacing in a union of tuples + assert tuple_replace_at(union(tuple([integer()]), tuple([atom()])), 0, boolean()) == + tuple([boolean()]) + + # Test replacing in an intersection of tuples + assert intersection(tuple([integer(), atom()]), tuple([term(), boolean()])) + |> tuple_replace_at(1, float()) == tuple([integer(), float()]) + + # Replacing in a difference where the negation actually constrains the + # positive (not just by arity). The replaced position drops its negative + # constraint, the other positions keep theirs. + assert difference(tuple([atom(), atom()]), tuple([atom([:a]), term()])) + |> tuple_replace_at(0, boolean()) + |> equal?(tuple([boolean(), atom()])) + + assert difference(tuple([atom(), atom()]), tuple([atom([:a]), term()])) + |> tuple_replace_at(1, boolean()) + |> equal?(difference(tuple([atom(), boolean()]), tuple([atom([:a]), boolean()]))) + + # Errors must propagate even when the replacement value is dynamic + assert tuple_replace_at(integer(), 0, dynamic()) == :badtuple + assert tuple_replace_at(term(), 0, dynamic()) == :badtuple + assert tuple_replace_at(tuple([atom([:ok])]), 1, dynamic()) == :badindex + assert tuple_replace_at(empty_tuple(), 0, dynamic()) == :badindex + + # Out-of-bounds writes to a dynamic fixed-size tuple must fail with :badindex + assert tuple_replace_at(dynamic(tuple([atom([:ok]), term()])), 2, binary()) == + :badindex + + assert tuple_replace_at(dynamic(tuple([atom([:ok])])), 1, binary()) == :badindex + + # Test replacing in a complex union involving dynamic + assert union(tuple([integer(), atom()]), dynamic(tuple([float(), binary()]))) + |> tuple_replace_at(1, boolean()) + |> equal?( + union( + tuple([integer(), boolean()]), + dynamic(tuple([float(), boolean()])) + ) + ) + + # Successfully replacing at position `index` in a tuple means that the dynamic + # values that succeed are intersected with tuples of size at least `index + 1` + assert dynamic(tuple()) + |> tuple_replace_at(0, boolean()) + |> equal?(dynamic(open_tuple([boolean()]))) + + assert dynamic(term()) + |> tuple_replace_at(0, boolean()) + |> equal?(dynamic(open_tuple([boolean()]))) + + assert dynamic(union(tuple(), integer())) + |> tuple_replace_at(1, boolean()) + |> equal?(dynamic(open_tuple([term(), boolean()]))) + end + test "tuple_values" do assert tuple_values(term()) == :badtuple assert tuple_values(dynamic()) == dynamic() diff --git a/lib/elixir/test/elixir/module/types/expr_test.exs b/lib/elixir/test/elixir/module/types/expr_test.exs index 5ace329d3a..9fa57f16c2 100644 --- a/lib/elixir/test/elixir/module/types/expr_test.exs +++ b/lib/elixir/test/elixir/module/types/expr_test.exs @@ -932,6 +932,77 @@ defmodule Module.Types.ExprTest do """ end + test "put_elem/3" do + assert typecheck!(put_elem({:ok, 123}, 0, "foo")) == tuple([binary(), integer()]) + assert typecheck!(put_elem({:ok, 123}, 1, "foo")) == tuple([atom([:ok]), binary()]) + + assert typecheck!([x], put_elem({:ok, x}, 0, "foo")) == + dynamic(tuple([binary(), term()])) + + assert typecheck!([x], put_elem({:ok, x}, 1, "foo")) == + dynamic(tuple([atom([:ok]), binary()])) + + assert typeerror!([<>], put_elem(x, 0, "foo")) |> strip_ansi() == + ~l""" + incompatible types given to Kernel.put_elem/3: + + put_elem(x, 0, "foo") + + given types: + + float(), integer(), binary() + + but expected one of: + + {...}, integer(), term() + + where "x" was given the type: + + # type: float() + # from: types_test.ex:LINE-1 + <> + """ + + assert typeerror!(put_elem({:ok, 123}, 2, "foo")) == + ~l""" + expected a tuple with at least 3 elements in Kernel.put_elem/3: + + put_elem({:ok, 123}, 2, "foo") + + the given type does not have the given index: + + {:ok, integer()} + """ + + assert typeerror!([x], put_elem({:ok, x}, 2, "foo")) |> strip_ansi() == + ~l""" + expected a tuple with at least 3 elements in Kernel.put_elem/3: + + put_elem({:ok, x}, 2, "foo") + + the given type does not have the given index: + + dynamic({:ok, term()}) + + where "x" was given the type: + + # type: dynamic() + # from: types_test.ex:LINE-1 + x + """ + + assert typeerror!(put_elem({1, 2}, -1, :x)) == + ~l""" + expected a non-negative integer as index in Kernel.put_elem/3: + + put_elem({1, 2}, -1, :x) + + got the index: + + -1 + """ + end + test "Tuple.duplicate/2" do assert typecheck!(Tuple.duplicate(123, 0)) == tuple([]) assert typecheck!(Tuple.duplicate(123, 1)) == tuple([integer()]) From 8e52e8db6bf23dd5ffdb4af34588a70963261d43 Mon Sep 17 00:00:00 2001 From: Guillaume Duboc <27832828+gldubc@users.noreply.github.com> Date: Thu, 14 May 2026 19:15:55 +0200 Subject: [PATCH 2/3] Make tuple_insert_at preserve static part of a type --- lib/elixir/lib/module/types/descr.ex | 18 +++++++++++++++--- .../test/elixir/module/types/descr_test.exs | 12 ++++++++++++ .../test/elixir/module/types/expr_test.exs | 10 ++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index be980e1465..167f558610 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -5853,10 +5853,22 @@ defmodule Module.Types.Descr do :error -> tuple_insert_at_checked(descr, index, type) - {dynamic_type, _static} -> + {dynamic_type, static_type} -> case tuple_insert_at_checked(descr, index, dynamic_type) do - atom when atom in [:badtuple, :badindex] -> atom - result -> dynamic(result) + dynamic_result when is_descr(dynamic_result) -> + dynamic_result = dynamic(dynamic_result) + + if empty?(static_type) do + dynamic_result + else + case tuple_insert_at_checked(descr, index, static_type) do + static_result when is_descr(static_result) -> union(dynamic_result, static_result) + error -> error + end + end + + error -> + error end end end diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index 474efd9c10..cfc5b2b8a0 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -1844,6 +1844,18 @@ defmodule Module.Types.DescrTest do assert tuple_insert_at(tuple([integer(), atom()]), 1, dynamic()) == dynamic(tuple([integer(), term(), atom()])) + assert tuple_insert_at( + tuple([boolean()]), + 1, + union(dynamic(integer()), atom([:inserted])) + ) + |> equal?( + union( + tuple([boolean(), atom([:inserted])]), + dynamic(tuple([boolean(), integer()])) + ) + ) + # Test inserting into a dynamic tuple assert tuple_insert_at(dynamic(tuple([integer(), atom()])), 1, boolean()) == dynamic(tuple([integer(), boolean(), atom()])) diff --git a/lib/elixir/test/elixir/module/types/expr_test.exs b/lib/elixir/test/elixir/module/types/expr_test.exs index 9fa57f16c2..2c9c50974b 100644 --- a/lib/elixir/test/elixir/module/types/expr_test.exs +++ b/lib/elixir/test/elixir/module/types/expr_test.exs @@ -838,6 +838,16 @@ defmodule Module.Types.ExprTest do assert typecheck!(Tuple.insert_at({:ok, 123}, 2, "foo")) == tuple([atom([:ok]), integer(), binary()]) + assert typeerror!( + [x], + ( + value = if :rand.uniform() > 0.5, do: :inserted, else: x + tuple = Tuple.insert_at({:ok}, 1, value) + Integer.to_string(elem(tuple, 1)) + ) + ) + |> strip_ansi() =~ "incompatible types given to Integer.to_string/1" + assert typeerror!([<>], Tuple.insert_at(x, 0, "foo")) |> strip_ansi() == ~l""" incompatible types given to Tuple.insert_at/3: From e52da49270d1932f992fed0d4561fdbe320e368e Mon Sep 17 00:00:00 2001 From: Guillaume Duboc <27832828+gldubc@users.noreply.github.com> Date: Thu, 14 May 2026 19:39:02 +0200 Subject: [PATCH 3/3] Implement replace_at using delete + insert --- lib/elixir/lib/module/types/descr.ex | 81 ++-------------------------- 1 file changed, 3 insertions(+), 78 deletions(-) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index 167f558610..824242e650 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -5942,90 +5942,15 @@ defmodule Module.Types.Descr do It returns the same as `tuple_fetch/2`. """ - def tuple_replace_at(:term, _key, _type), do: :badtuple - def tuple_replace_at(descr, index, type) when is_integer(index) and index >= 0 do - case :maps.take(:dynamic, unfold(type)) do - :error -> - tuple_replace_at_checked(descr, index, type) - - {dynamic_type, _static} -> - case tuple_replace_at_checked(descr, index, dynamic_type) do - atom when atom in [:badtuple, :badindex] -> atom - result -> dynamic(result) - end + case tuple_delete_at(descr, index) do + descr when is_descr(descr) -> tuple_insert_at(descr, index, type) + error -> error end end def tuple_replace_at(_, _, _), do: :badindex - defp tuple_replace_at_checked(descr, index, type) do - case :maps.take(:dynamic, descr) do - :error -> - # Note: the empty type is not a valid input - is_proper_tuple? = descr_key?(descr, :tuple) and non_empty_tuple_only?(descr) - is_proper_size? = tuple_of_size_at_least_static?(descr, index + 1) - - cond do - is_proper_tuple? and is_proper_size? -> tuple_replace_static(descr, index, type) - is_proper_tuple? -> :badindex - true -> :badtuple - end - - {dynamic, static} -> - is_proper_tuple? = descr_key?(dynamic, :tuple) and tuple_only?(static) - is_proper_size? = tuple_of_size_at_least_static?(static, index + 1) - - cond do - is_proper_tuple? and is_proper_size? -> - static_result = tuple_replace_static(static, index, type) - - # Prune for dynamic values that make the operation succeed. - dynamic_input = intersection(dynamic, tuple_of_size_at_least(index + 1)) - - if empty?(dynamic_input) and empty?(static) do - :badindex - else - dynamic_result = tuple_replace_static(dynamic_input, index, type) - union(dynamic(dynamic_result), static_result) - end - - # Highlight the case where the issue is an index out of range from the tuple - is_proper_tuple? -> - :badindex - - true -> - :badtuple - end - end - end - - defp tuple_replace_static(descr, _, _) when descr == @none, do: none() - - # Unlike insert/delete, replace is not injective at the replaced index, so - # transformed negative leaves would discard valid outputs. We eliminate - # negations first by converting to a positive-only DNF and replacing in - # each disjunct. - defp tuple_replace_static(descr, index, type) do - Map.update!(descr, :tuple, fn bdd -> - tuple_bdd_to_dnf_no_negations(bdd) - |> Enum.reduce(:bdd_bot, fn {tag, elements}, acc -> - # If the tuple is open, then we want List.replace_at to update the correct - # index, which requires filling the tuple with `term()` values first. - # Closed tuples of an incorrect size will have been cancelled by the - # earlier intersection with `tuple_of_size_at_least`. - elements = - if tag == :open and length(elements) < index + 1 do - tuple_fill(elements, index + 1) - else - elements - end - - bdd_union(acc, tuple_new(tag, List.replace_at(elements, index, type))) - end) - end) - end - defp tuple_of_size_at_least(n) when is_integer(n) and n >= 0 do %{tuple: tuple_new(:open, List.duplicate(term(), n))} end