From 875769960641039dbe0fd6f6769d1ff29642d8df Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Thu, 14 May 2026 11:20:26 +0200 Subject: [PATCH 1/4] Typesystem apply rules for `put_elem` --- lib/elixir/lib/module/types/apply.ex | 21 ++++++ lib/elixir/lib/module/types/descr.ex | 75 +++++++++++++++++++ .../test/elixir/module/types/descr_test.exs | 74 ++++++++++++++++++ .../test/elixir/module/types/expr_test.exs | 43 +++++++++++ 4 files changed, 213 insertions(+) diff --git a/lib/elixir/lib/module/types/apply.ex b/lib/elixir/lib/module/types/apply.ex index 3170cb96ff..488d8a2c85 100644 --- a/lib/elixir/lib/module/types/apply.ex +++ b/lib/elixir/lib/module/types/apply.ex @@ -479,6 +479,27 @@ defmodule Module.Types.Apply do end 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 bbef71f22a..d4c8a42012 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -5919,6 +5919,81 @@ 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, _static} -> dynamic(tuple_replace_at_checked(descr, index, dynamic)) + 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 intersection succeed + dynamic_result = + intersection(dynamic, tuple_of_size_at_least(index + 1)) + |> tuple_replace_static(index, type) + + union(dynamic(dynamic_result), static_result) + + # 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() + + defp tuple_replace_static(descr, index, type) do + Map.update!(descr, :tuple, fn bdd -> + bdd_map(bdd, fn bdd_leaf(tag, elements) -> + # 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 be ignored (they are 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_leaf_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 53c49536b7..44f01eb146 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -1883,6 +1883,80 @@ defmodule Module.Types.DescrTest do |> equal?(dynamic(open_tuple([term(), boolean()]))) 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()]) + + # Test replacing in a difference of tuples + assert difference(tuple([integer(), atom(), boolean()]), tuple([term(), term()])) + |> tuple_replace_at(1, float()) + |> equal?(tuple([integer(), float(), boolean()])) + + # 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 dc6831f1b5..2b21d26a5f 100644 --- a/lib/elixir/test/elixir/module/types/expr_test.exs +++ b/lib/elixir/test/elixir/module/types/expr_test.exs @@ -899,6 +899,49 @@ 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()} + """ + end + test "Tuple.duplicate/2" do assert typecheck!(Tuple.duplicate(123, 0)) == tuple([]) assert typecheck!(Tuple.duplicate(123, 1)) == tuple([integer()]) From 7e2005a7a3a2e0cf7e69b49dd3ef35f251e46ba9 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Thu, 14 May 2026 12:02:20 +0200 Subject: [PATCH 2/4] Fix issues in insert_element, delete_element and setelement surface badtuple, badindex in dynamic require dynamic upper bound eliminate negations before replacing stronger difference tests fixed off by one in dynamic pruning in delete_element --- lib/elixir/lib/module/types/descr.ex | 50 ++++++++++++----- .../test/elixir/module/types/descr_test.exs | 55 +++++++++++++++++++ .../test/elixir/module/types/expr_test.exs | 51 +++++++++++++++++ 3 files changed, 143 insertions(+), 13 deletions(-) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index d4c8a42012..ae368b238b 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -5850,8 +5850,16 @@ defmodule Module.Types.Descr do def tuple_insert_at(descr, index, type) when is_integer(index) and index >= 0 do case :maps.take(:dynamic, unfold(type)) do - :error -> tuple_insert_at_checked(descr, index, type) - {dynamic, _static} -> dynamic(tuple_insert_at_checked(descr, index, dynamic)) + :error -> + tuple_insert_at_checked(descr, index, type) + + {dynamic_type, _static} -> + # Errors about the tuple/index do not become "dynamic errors" when the + # inserted value happens to be dynamic — propagate them as-is. + case tuple_insert_at_checked(descr, index, dynamic_type) do + atom when atom in [:badtuple, :badindex] -> atom + result -> dynamic(result) + end end end @@ -5928,8 +5936,16 @@ defmodule Module.Types.Descr do 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, _static} -> dynamic(tuple_replace_at_checked(descr, index, dynamic)) + :error -> + tuple_replace_at_checked(descr, index, type) + + {dynamic_type, _static} -> + # Errors about the tuple/index do not become "dynamic errors" when the + # replacement value happens to be dynamic — propagate them as-is. + case tuple_replace_at_checked(descr, index, dynamic_type) do + atom when atom in [:badtuple, :badindex] -> atom + result -> dynamic(result) + end end end @@ -5956,12 +5972,15 @@ defmodule Module.Types.Descr do is_proper_tuple? and is_proper_size? -> static_result = tuple_replace_static(static, index, type) - # Prune for dynamic values that make the intersection succeed - dynamic_result = - intersection(dynamic, tuple_of_size_at_least(index + 1)) - |> tuple_replace_static(index, type) + # Prune for dynamic values that make the operation succeed. + dynamic_input = intersection(dynamic, tuple_of_size_at_least(index + 1)) - union(dynamic(dynamic_result), static_result) + 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? -> @@ -5976,12 +5995,17 @@ defmodule Module.Types.Descr do defp tuple_replace_static(descr, _, _) when descr == @none, do: none() defp tuple_replace_static(descr, index, type) do + # 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. Map.update!(descr, :tuple, fn bdd -> - bdd_map(bdd, fn bdd_leaf(tag, elements) -> + 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 be ignored (they are cancelled by the earlier - # intersection with `tuple_of_size_at_least`). + # 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) @@ -5989,7 +6013,7 @@ defmodule Module.Types.Descr do elements end - bdd_leaf_new(tag, List.replace_at(elements, index, type)) + bdd_union(acc, tuple_new(tag, List.replace_at(elements, index, type))) 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 44f01eb146..8b252d711d 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -1808,6 +1808,25 @@ defmodule Module.Types.DescrTest do assert dynamic(union(tuple(), integer())) |> tuple_delete_at(1) |> equal?(dynamic(tuple_of_size_at_least(1))) + + # Deleting beyond a dynamic fixed-size tuple must fail with :badindex. + # In particular, deleting index N from a dynamic N-tuple must NOT + # silently return the original tuple type (off-by-one pruning bug). + assert tuple_delete_at(dynamic(tuple([atom([:ok]), term()])), 2) == :badindex + assert tuple_delete_at(dynamic(tuple([atom([:a]), atom([:b])])), 2) == :badindex + assert tuple_delete_at(dynamic(tuple([atom([:ok])])), 1) == :badindex + + # Deletion is not injective at the deleted position; the negative + # constraint at that position must not be preserved. + assert difference(tuple([atom(), atom()]), tuple([atom([:a]), term()])) + |> tuple_delete_at(0) + |> equal?(tuple([atom()])) + + # Negative constraints at positions OTHER than the deleted one are + # preserved (after shifting). + assert difference(tuple([atom(), atom()]), tuple([atom([:a]), term()])) + |> tuple_delete_at(1) + |> equal?(difference(tuple([atom()]), tuple([atom([:a])]))) end test "tuple_insert_at" do @@ -1881,6 +1900,19 @@ defmodule Module.Types.DescrTest do assert dynamic(union(tuple(), integer())) |> tuple_insert_at(1, boolean()) |> equal?(dynamic(open_tuple([term(), boolean()]))) + + # Errors must propagate even when the inserted value is dynamic + assert tuple_insert_at(integer(), 0, dynamic()) == :badtuple + assert tuple_insert_at(term(), 0, dynamic()) == :badtuple + assert tuple_insert_at(tuple([atom([:ok])]), 2, dynamic()) == :badindex + assert tuple_insert_at(tuple([atom([:ok])]), -1, dynamic()) == :badindex + + # Out-of-bounds insertions into a dynamic fixed-size tuple must fail + # with :badindex (rather than silently producing dynamic(none())). + assert tuple_insert_at(dynamic(tuple([atom([:ok]), term()])), 3, binary()) == + :badindex + + assert tuple_insert_at(dynamic(tuple([atom([:ok])])), 2, binary()) == :badindex end test "tuple_replace_at" do @@ -1932,6 +1964,29 @@ defmodule Module.Types.DescrTest do |> tuple_replace_at(1, float()) |> equal?(tuple([integer(), float(), boolean()])) + # 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()) diff --git a/lib/elixir/test/elixir/module/types/expr_test.exs b/lib/elixir/test/elixir/module/types/expr_test.exs index 2b21d26a5f..4f87cbe985 100644 --- a/lib/elixir/test/elixir/module/types/expr_test.exs +++ b/lib/elixir/test/elixir/module/types/expr_test.exs @@ -858,6 +858,23 @@ defmodule Module.Types.ExprTest do {:ok, integer()} """ + + assert typeerror!([x], Tuple.insert_at({:ok, x}, 3, "foo")) |> strip_ansi() == + ~l""" + expected a tuple with at least 3 elements in Tuple.insert_at/3: + + Tuple.insert_at({:ok, x}, 3, "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 + """ end test "Tuple.delete_at/2" do @@ -897,6 +914,23 @@ defmodule Module.Types.ExprTest do {:ok, integer()} """ + + assert typeerror!([x], Tuple.delete_at({:ok, x}, 2)) |> strip_ansi() == + ~l""" + expected a tuple with at least 3 elements in Tuple.delete_at/2: + + Tuple.delete_at({:ok, x}, 2) + + 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 + """ end test "put_elem/3" do @@ -940,6 +974,23 @@ defmodule Module.Types.ExprTest do {: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 + """ end test "Tuple.duplicate/2" do From b8289d650b9262e8ed8905459d236425ca0c1ed9 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Thu, 14 May 2026 12:08:08 +0200 Subject: [PATCH 3/4] fix invalid error message with negative index --- lib/elixir/lib/module/types/apply.ex | 35 +++++++++++++++ .../test/elixir/module/types/expr_test.exs | 44 +++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/lib/elixir/lib/module/types/apply.ex b/lib/elixir/lib/module/types/apply.ex index 488d8a2c85..37d4cdee14 100644 --- a/lib/elixir/lib/module/types/apply.ex +++ b/lib/elixir/lib/module/types/apply.ex @@ -447,6 +447,9 @@ defmodule Module.Types.Apply do :badtuple -> remote_error(:erlang, :element, [integer(), tuple_type], expr, stack, context) + :badindex when index < 1 -> + remote_error({:negindex, index - 1}, :erlang, :element, 2, expr, stack, context) + :badindex -> remote_error({:badindex, index, tuple_type}, :erlang, :element, 2, expr, stack, context) end @@ -473,6 +476,9 @@ defmodule Module.Types.Apply do args_types = [integer(), tuple_type, elem_type] remote_error(:erlang, :insert_element, args_types, expr, stack, context) + :badindex when index < 1 -> + remote_error({:negindex, index - 1}, :erlang, :insert_element, 3, expr, stack, context) + :badindex -> error = {:badindex, index - 1, tuple_type} remote_error(error, :erlang, :insert_element, 3, expr, stack, context) @@ -494,6 +500,9 @@ defmodule Module.Types.Apply do args_types = [integer(), tuple_type, elem_type] remote_error(:erlang, :setelement, args_types, expr, stack, context) + :badindex when index < 1 -> + remote_error({:negindex, index - 1}, :erlang, :setelement, 3, expr, stack, context) + :badindex -> error = {:badindex, index, tuple_type} remote_error(error, :erlang, :setelement, 3, expr, stack, context) @@ -512,6 +521,9 @@ defmodule Module.Types.Apply do :badtuple -> remote_error(:erlang, :delete_element, [integer(), tuple_type], expr, stack, context) + :badindex when index < 1 -> + remote_error({:negindex, index - 1}, :erlang, :delete_element, 2, expr, stack, context) + :badindex -> error = {:badindex, index, tuple_type} remote_error(error, :erlang, :delete_element, 2, expr, stack, context) @@ -1829,6 +1841,29 @@ defmodule Module.Types.Apply do } end + def format_diagnostic({{:negindex, index}, mfac, expr, context}) do + traces = collect_traces(expr, context) + {mod, fun, arity, _converter} = mfac + mfa = Exception.format_mfa(mod, fun, arity) + + %{ + details: %{typing_traces: traces}, + message: + IO.iodata_to_binary([ + """ + expected a non-negative integer as index in #{mfa}: + + #{expr_to_string(expr) |> indent(4)} + + got the index: + + #{index} + """, + format_traces(traces) + ]) + } + end + def format_diagnostic({{:badkeydomain, map, key, error}, mfac, expr, context}) do {mod, fun, arity, _converter} = mfac mfa = Exception.format_mfa(mod, fun, arity) diff --git a/lib/elixir/test/elixir/module/types/expr_test.exs b/lib/elixir/test/elixir/module/types/expr_test.exs index 4f87cbe985..224eb865eb 100644 --- a/lib/elixir/test/elixir/module/types/expr_test.exs +++ b/lib/elixir/test/elixir/module/types/expr_test.exs @@ -813,6 +813,17 @@ defmodule Module.Types.ExprTest do {:ok, integer()} """ + + assert typeerror!(elem({1, 2}, -1)) == + ~l""" + expected a non-negative integer as index in Kernel.elem/2: + + elem({1, 2}, -1) + + got the index: + + -1 + """ end test "Tuple.insert_at/3" do @@ -875,6 +886,17 @@ defmodule Module.Types.ExprTest do # from: types_test.ex:LINE-1 x """ + + assert typeerror!(Tuple.insert_at({1, 2}, -1, :x)) == + ~l""" + expected a non-negative integer as index in Tuple.insert_at/3: + + Tuple.insert_at({1, 2}, -1, :x) + + got the index: + + -1 + """ end test "Tuple.delete_at/2" do @@ -931,6 +953,17 @@ defmodule Module.Types.ExprTest do # from: types_test.ex:LINE-1 x """ + + assert typeerror!(Tuple.delete_at({1, 2}, -1)) == + ~l""" + expected a non-negative integer as index in Tuple.delete_at/2: + + Tuple.delete_at({1, 2}, -1) + + got the index: + + -1 + """ end test "put_elem/3" do @@ -991,6 +1024,17 @@ defmodule Module.Types.ExprTest do # 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 From af6b56abe7ff8fca40609a2eb859464a25c8c26e Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Thu, 14 May 2026 12:12:28 +0200 Subject: [PATCH 4/4] fix insert_at 0 with dynamic --- lib/elixir/test/elixir/module/types/descr_test.exs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index 8b252d711d..5e72142ca3 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -1913,6 +1913,16 @@ defmodule Module.Types.DescrTest do :badindex assert tuple_insert_at(dynamic(tuple([atom([:ok])])), 2, binary()) == :badindex + + # Even at index 0 (where the size constraint is vacuous) the dynamic + # upper bound must still be intersected with the tuple domain so that + # non-tuple alternatives are pruned. `Tuple.insert_at/3` always returns + # a tuple, so an integer alternative cannot survive the operation. + assert tuple_insert_at(dynamic(term()), 0, boolean()) == + dynamic(open_tuple([boolean()])) + + assert tuple_insert_at(dynamic(union(tuple(), integer())), 0, boolean()) == + dynamic(open_tuple([boolean()])) end test "tuple_replace_at" do