From b5608f5ac1f8b9adc84f8b2aa221669eb06998e2 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Wed, 15 Apr 2026 10:00:21 +0200 Subject: [PATCH 1/3] Preserve static part on dynamic container --- lib/elixir/lib/module/types/descr.ex | 217 ++++++++++++------ .../test/elixir/module/types/descr_test.exs | 37 ++- .../test/elixir/module/types/expr_test.exs | 5 +- .../test/elixir/module/types/map_test.exs | 21 +- 4 files changed, 207 insertions(+), 73 deletions(-) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index 16eac25cade..a921b724144 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -2143,9 +2143,31 @@ defmodule Module.Types.Descr do # # none() types can be given and, while stored, it means the list type is empty. defp list_descr(list_type, last_type, empty?) do - {list_dynamic?, list_type} = list_pop_dynamic(list_type) - {last_dynamic?, last_type} = list_pop_dynamic(last_type) + {dynamic_list_type, static_list_type} = pop_dynamic(list_type) + {dynamic_last_type, static_last_type} = pop_dynamic(last_type) + dynamic? = dynamic_list_type != static_list_type or dynamic_last_type != static_last_type + static_possible? = not empty?(static_list_type) and not empty?(static_last_type) + dynamic_descr = list_descr_build(dynamic_list_type, dynamic_last_type, empty?) + + cond do + empty?(dynamic_descr) -> + %{} + + dynamic? -> + if not static_possible? do + %{dynamic: dynamic_descr} + else + static_descr = list_descr_build(static_list_type, static_last_type, empty?) + Map.put(static_descr, :dynamic, dynamic_descr) + end + + true -> + dynamic_descr + end + end + + defp list_descr_build(list_type, last_type, empty?) do list_part = if last_type == :term do list_new(:term, :term) @@ -2176,13 +2198,7 @@ defmodule Module.Types.Descr do end end - list_descr = - if empty?, do: %{list: list_part, bitmap: @bit_empty_list}, else: %{list: list_part} - - case list_dynamic? or last_dynamic? do - true -> %{dynamic: list_descr} - false -> list_descr - end + if empty?, do: %{list: list_part, bitmap: @bit_empty_list}, else: %{list: list_part} end defp list_new(list_type, last_type), do: bdd_leaf_new(list_type, last_type) @@ -2231,15 +2247,6 @@ defmodule Module.Types.Descr do end) end - defp list_pop_dynamic(:term), do: {false, :term} - - defp list_pop_dynamic(descr) do - case :maps.take(:dynamic, descr) do - :error -> {false, descr} - {dynamic, _} -> {true, dynamic} - end - end - defp list_tail_unfold(:term), do: @not_non_empty_list defp list_tail_unfold(other), do: Map.delete(other, :list) @@ -2784,8 +2791,29 @@ defmodule Module.Types.Descr do defp domain_key_to_descr(:list), do: @list_top defp map_descr(tag, pairs) do - {fields, domains, dynamic?} = map_descr_pairs(pairs, [], @fields_new, false) + {fields, domains, dynamic_fields, dynamic_domains, dynamic?, static_possible?} = + map_descr_pairs(pairs, [], @fields_new, [], @fields_new, false, true) + + dynamic_descr = map_descr_build(tag, dynamic_fields, dynamic_domains) + + cond do + empty?(dynamic_descr) -> + %{} + + dynamic? -> + if not static_possible? do + %{dynamic: dynamic_descr} + else + static_descr = map_descr_build(tag, fields, domains) + Map.put(static_descr, :dynamic, dynamic_descr) + end + + true -> + dynamic_descr + end + end + defp map_descr_build(tag, fields, domains) do map_new = if not is_fields_empty(domains) do domains = @@ -2801,10 +2829,7 @@ defmodule Module.Types.Descr do map_new(tag, fields) end - case dynamic? do - true -> %{dynamic: %{map: map_new}} - false -> %{map: map_new} - end + %{map: map_new} end defp map_put_domain(domain, domain_keys, value) when is_list(domain_keys) do @@ -2825,28 +2850,89 @@ defmodule Module.Types.Descr do defp map_put_domain(domain, [], _initial, _value), do: domain - defp map_descr_pairs([{key, :term} | rest], fields, domain, dynamic?) do + defp map_descr_pairs( + [{key, :term} | rest], + fields, + domain, + dynamic_fields, + dynamic_domain, + dynamic?, + static_possible? + ) do case is_atom(key) do - true -> map_descr_pairs(rest, [{key, :term} | fields], domain, dynamic?) - false -> map_descr_pairs(rest, fields, map_put_domain(domain, key, :term), dynamic?) + true -> + map_descr_pairs( + rest, + [{key, :term} | fields], + domain, + [{key, :term} | dynamic_fields], + dynamic_domain, + dynamic?, + static_possible? + ) + + false -> + map_descr_pairs( + rest, + fields, + map_put_domain(domain, key, :term), + dynamic_fields, + map_put_domain(dynamic_domain, key, :term), + dynamic?, + static_possible? + ) end end - defp map_descr_pairs([{key, value} | rest], fields, domain, dynamic?) do - {value, dynamic?} = - case :maps.take(:dynamic, value) do - :error -> {value, dynamic?} - {dynamic, _static} -> {dynamic, true} - end + defp map_descr_pairs( + [{key, value} | rest], + fields, + domain, + dynamic_fields, + dynamic_domain, + dynamic?, + static_possible? + ) do + {dynamic_value, static_value} = pop_dynamic(value) + dynamic? = dynamic? or dynamic_value != static_value + static_possible? = static_possible? and not empty?(static_value) case is_atom(key) do - true -> map_descr_pairs(rest, [{key, value} | fields], domain, dynamic?) - false -> map_descr_pairs(rest, fields, map_put_domain(domain, key, value), dynamic?) + true -> + map_descr_pairs( + rest, + [{key, static_value} | fields], + domain, + [{key, dynamic_value} | dynamic_fields], + dynamic_domain, + dynamic?, + static_possible? + ) + + false -> + map_descr_pairs( + rest, + fields, + map_put_domain(domain, key, static_value), + dynamic_fields, + map_put_domain(dynamic_domain, key, dynamic_value), + dynamic?, + static_possible? + ) end end - defp map_descr_pairs([], fields, domain, dynamic?) do - {fields_from_reverse_list(fields), domain, dynamic?} + defp map_descr_pairs( + [], + fields, + domain, + dynamic_fields, + dynamic_domain, + dynamic?, + static_possible? + ) do + {fields_from_reverse_list(fields), domain, fields_from_reverse_list(dynamic_fields), + dynamic_domain, dynamic?, static_possible?} end # Gets the default type associated to atom keys in a map. @@ -4895,46 +4981,47 @@ defmodule Module.Types.Descr do # - {atom(), boolean(), ...} is encoded as {:open, [atom(), boolean()]} defp tuple_descr(tag, fields) do - case tuple_descr(fields, [], false) do - :empty -> %{} - {fields, true} -> %{dynamic: %{tuple: tuple_new(tag, Enum.reverse(fields))}} - {_, false} -> %{tuple: tuple_new(tag, fields)} + case tuple_descr(fields, [], [], false, true) do + :empty -> + %{} + + {static_fields, dynamic_fields, true, static_possible?} -> + dynamic_descr = %{tuple: tuple_new(tag, :lists.reverse(dynamic_fields))} + + if not static_possible? do + %{dynamic: dynamic_descr} + else + static_descr = %{tuple: tuple_new(tag, :lists.reverse(static_fields))} + Map.put(static_descr, :dynamic, dynamic_descr) + end + + {fields, _dynamic_fields, false, _static_possible?} -> + %{tuple: tuple_new(tag, :lists.reverse(fields))} end end - defp tuple_descr([:term | rest], acc, dynamic?) do - tuple_descr(rest, [:term | acc], dynamic?) + defp tuple_descr([:term | rest], acc, dynamic_acc, dynamic?, static_possible?) do + tuple_descr(rest, [:term | acc], [:term | dynamic_acc], dynamic?, static_possible?) end - defp tuple_descr([value | rest], acc, dynamic?) do - # Check if the static part is empty - static_empty? = - case value do - # Has dynamic component, check static separately - %{dynamic: _} -> false - _ -> empty?(value) - end + defp tuple_descr([value | rest], acc, dynamic_acc, dynamic?, static_possible?) do + {dynamic_value, static_value} = pop_dynamic(value) - if static_empty? do + if empty?(dynamic_value) do :empty else - case :maps.take(:dynamic, value) do - :error -> - tuple_descr(rest, [value | acc], dynamic?) - - {dynamic, _static} -> - # Check if dynamic component is empty - if empty?(dynamic) do - :empty - else - tuple_descr(rest, [dynamic | acc], true) - end - end + tuple_descr( + rest, + [static_value | acc], + [dynamic_value | dynamic_acc], + dynamic? or dynamic_value != static_value, + static_possible? and not empty?(static_value) + ) end end - defp tuple_descr([], acc, dynamic?) do - {acc, dynamic?} + defp tuple_descr([], acc, dynamic_acc, dynamic?, static_possible?) do + {acc, dynamic_acc, dynamic?, static_possible?} end defp tuple_new(tag, elements), do: bdd_leaf_new(tag, elements) diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index f14c5056995..54f756840ae 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -810,7 +810,7 @@ defmodule Module.Types.DescrTest do test "map hoists dynamic" do assert dynamic(open_map(a: integer())) == open_map(a: dynamic(integer())) - assert dynamic(open_map(a: union(integer(), binary()))) == + assert union(open_map(a: binary()), dynamic(open_map(a: union(integer(), binary())))) == open_map(a: dynamic(integer()) |> union(binary())) # For domains too @@ -821,7 +821,40 @@ defmodule Module.Types.DescrTest do # if_set on dynamic fields also must work t1 = dynamic(open_map(a: if_set(integer()))) t2 = open_map(a: if_set(dynamic(integer()))) - assert t1 == t2 + assert union(open_map(a: not_set()), t1) == t2 + end + + test "tuple preserves static part of gradual elements" do + x = union(atom([:ok]), dynamic(integer())) + + assert tuple([x, atom([:x])]) == %{ + tuple: {:closed, [atom([:ok]), atom([:x])]}, + dynamic: %{tuple: {:closed, [upper_bound(x), atom([:x])]}} + } + + assert subtype?(tuple([atom([:ok]), atom([:x])]), tuple([x, atom([:x])])) + end + + test "closed_map preserves static part of gradual values" do + x = union(atom([:ok]), dynamic(integer())) + + assert closed_map(a: x) == %{ + map: {:closed, [a: atom([:ok])]}, + dynamic: %{map: {:closed, [a: upper_bound(x)]}} + } + + assert subtype?(closed_map(a: atom([:ok])), closed_map(a: x)) + end + + test "non_empty_list preserves static part of gradual head types" do + x = union(atom([:ok]), dynamic(integer())) + + assert non_empty_list(x) == %{ + list: {atom([:ok]), empty_list()}, + dynamic: %{list: {upper_bound(x), empty_list()}} + } + + assert subtype?(non_empty_list(atom([:ok])), non_empty_list(x)) end end diff --git a/lib/elixir/test/elixir/module/types/expr_test.exs b/lib/elixir/test/elixir/module/types/expr_test.exs index 701d6402c8d..2302dba0b5d 100644 --- a/lib/elixir/test/elixir/module/types/expr_test.exs +++ b/lib/elixir/test/elixir/module/types/expr_test.exs @@ -60,7 +60,10 @@ defmodule Module.Types.ExprTest do assert typecheck!([:ok, 123]) == non_empty_list(union(atom([:ok]), integer())) assert typecheck!([:ok | 123]) == non_empty_list(atom([:ok]), integer()) - assert typecheck!([x], [:ok, x]) == dynamic(non_empty_list(term())) + + assert typecheck!([x], [:ok, x]) + |> equal?(union(non_empty_list(atom([:ok])), dynamic(non_empty_list(term())))) + assert typecheck!([x], [:ok | x]) == dynamic(non_empty_list(term(), term())) end diff --git a/lib/elixir/test/elixir/module/types/map_test.exs b/lib/elixir/test/elixir/module/types/map_test.exs index 106a5ecf8c7..07a6fb2e955 100644 --- a/lib/elixir/test/elixir/module/types/map_test.exs +++ b/lib/elixir/test/elixir/module/types/map_test.exs @@ -601,7 +601,10 @@ defmodule Module.Types.MapTest do describe "Map.pop_lazy/3" do test "checking" do assert typecheck!(Map.pop_lazy(%{key: 123}, :key, fn -> :error end)) == - dynamic(tuple([union(integer(), atom([:error])), empty_map()])) + union( + tuple([integer(), empty_map()]), + dynamic(tuple([union(integer(), atom([:error])), empty_map()])) + ) assert typecheck!([x], Map.pop_lazy(x, :key, fn -> :error end)) == dynamic(tuple([term(), open_map(key: not_set())])) @@ -626,13 +629,21 @@ defmodule Module.Types.MapTest do x = %{String.to_integer(x) => :before} Map.pop_lazy(x, 123, fn -> :after end) ) - ) == - dynamic( + ) + |> equal?( + union( tuple([ - atom([:before, :after]), + atom([:before]), closed_map([{domain_key(:integer), atom([:before])}]) - ]) + ]), + dynamic( + tuple([ + atom([:before, :after]), + closed_map([{domain_key(:integer), atom([:before])}]) + ]) + ) ) + ) end test "inference" do From 6f92d3c8aadf29b3bf6744aed72a52de5838c8a9 Mon Sep 17 00:00:00 2001 From: Guillaume Duboc <27832828+gldubc@users.noreply.github.com> Date: Tue, 12 May 2026 18:56:17 +0200 Subject: [PATCH 2/3] Refine dynamic container descriptors --- lib/elixir/lib/module/types/descr.ex | 209 ++++++------------ .../test/elixir/module/types/descr_test.exs | 56 +++-- .../test/elixir/module/types/map_test.exs | 14 +- 3 files changed, 95 insertions(+), 184 deletions(-) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index a921b724144..15b3e588f87 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -2143,31 +2143,27 @@ defmodule Module.Types.Descr do # # none() types can be given and, while stored, it means the list type is empty. defp list_descr(list_type, last_type, empty?) do + dynamic? = gradual?(list_type) or gradual?(last_type) {dynamic_list_type, static_list_type} = pop_dynamic(list_type) {dynamic_last_type, static_last_type} = pop_dynamic(last_type) - dynamic? = dynamic_list_type != static_list_type or dynamic_last_type != static_last_type - static_possible? = not empty?(static_list_type) and not empty?(static_last_type) - - dynamic_descr = list_descr_build(dynamic_list_type, dynamic_last_type, empty?) + dynamic_descr = list_descr_static(dynamic_list_type, dynamic_last_type, empty?) + # Just a syntactic check, to avoid a recursive empty? call + static_empty? = static_list_type == @none or static_last_type == @none cond do - empty?(dynamic_descr) -> - %{} + not dynamic? -> + dynamic_descr - dynamic? -> - if not static_possible? do - %{dynamic: dynamic_descr} - else - static_descr = list_descr_build(static_list_type, static_last_type, empty?) - Map.put(static_descr, :dynamic, dynamic_descr) - end + static_empty? -> + %{dynamic: dynamic_descr} true -> - dynamic_descr + list_descr_static(static_list_type, static_last_type, empty?) + |> Map.put(:dynamic, dynamic_descr) end end - defp list_descr_build(list_type, last_type, empty?) do + defp list_descr_static(list_type, last_type, empty?) do list_part = if last_type == :term do list_new(:term, :term) @@ -2791,29 +2787,19 @@ defmodule Module.Types.Descr do defp domain_key_to_descr(:list), do: @list_top defp map_descr(tag, pairs) do - {fields, domains, dynamic_fields, dynamic_domains, dynamic?, static_possible?} = - map_descr_pairs(pairs, [], @fields_new, [], @fields_new, false, true) + {fields, domains, dynamic_fields, dynamic_domains, dynamic?, static_empty?} = + map_descr_pairs(pairs) - dynamic_descr = map_descr_build(tag, dynamic_fields, dynamic_domains) + dynamic_descr = map_descr_static(tag, dynamic_fields, dynamic_domains) cond do - empty?(dynamic_descr) -> - %{} - - dynamic? -> - if not static_possible? do - %{dynamic: dynamic_descr} - else - static_descr = map_descr_build(tag, fields, domains) - Map.put(static_descr, :dynamic, dynamic_descr) - end - - true -> - dynamic_descr + not dynamic? -> dynamic_descr + static_empty? -> %{dynamic: dynamic_descr} + true -> Map.put(map_descr_static(tag, fields, domains), :dynamic, dynamic_descr) end end - defp map_descr_build(tag, fields, domains) do + defp map_descr_static(tag, fields, domains) do map_new = if not is_fields_empty(domains) do domains = @@ -2850,91 +2836,34 @@ defmodule Module.Types.Descr do defp map_put_domain(domain, [], _initial, _value), do: domain - defp map_descr_pairs( - [{key, :term} | rest], - fields, - domain, - dynamic_fields, - dynamic_domain, - dynamic?, - static_possible? - ) do - case is_atom(key) do - true -> - map_descr_pairs( - rest, - [{key, :term} | fields], - domain, - [{key, :term} | dynamic_fields], - dynamic_domain, - dynamic?, - static_possible? - ) + defp map_descr_pairs(pairs) do + {fields, domains, dynamic_fields, dynamic_domains, dynamic?, static_possible?} = + Enum.reduce(pairs, {[], @fields_new, [], @fields_new, false, false}, fn {key, value}, acc -> + map_descr_pair(key, value, acc) + end) - false -> - map_descr_pairs( - rest, - fields, - map_put_domain(domain, key, :term), - dynamic_fields, - map_put_domain(dynamic_domain, key, :term), - dynamic?, - static_possible? - ) - end + {fields_from_reverse_list(fields), domains, fields_from_reverse_list(dynamic_fields), + dynamic_domains, dynamic?, static_possible?} end - defp map_descr_pairs( - [{key, value} | rest], - fields, - domain, - dynamic_fields, - dynamic_domain, - dynamic?, - static_possible? + defp map_descr_pair( + key, + value, + {fields, domains, dynamic_fields, dynamic_domains, dynamic?, static_empty?} ) do {dynamic_value, static_value} = pop_dynamic(value) - dynamic? = dynamic? or dynamic_value != static_value - static_possible? = static_possible? and not empty?(static_value) + dynamic? = dynamic? or gradual?(value) + static_empty? = static_empty? or static_value == @none - case is_atom(key) do - true -> - map_descr_pairs( - rest, - [{key, static_value} | fields], - domain, - [{key, dynamic_value} | dynamic_fields], - dynamic_domain, - dynamic?, - static_possible? - ) - - false -> - map_descr_pairs( - rest, - fields, - map_put_domain(domain, key, static_value), - dynamic_fields, - map_put_domain(dynamic_domain, key, dynamic_value), - dynamic?, - static_possible? - ) + if is_atom(key) do + {[{key, static_value} | fields], domains, [{key, dynamic_value} | dynamic_fields], + dynamic_domains, dynamic?, static_empty?} + else + {fields, map_put_domain(domains, key, static_value), dynamic_fields, + map_put_domain(dynamic_domains, key, dynamic_value), dynamic?, static_empty?} end end - defp map_descr_pairs( - [], - fields, - domain, - dynamic_fields, - dynamic_domain, - dynamic?, - static_possible? - ) do - {fields_from_reverse_list(fields), domain, fields_from_reverse_list(dynamic_fields), - dynamic_domain, dynamic?, static_possible?} - end - # Gets the default type associated to atom keys in a map. defp map_key_tag_to_type(:open), do: term_or_optional() defp map_key_tag_to_type(:closed), do: not_set() @@ -4981,47 +4910,34 @@ defmodule Module.Types.Descr do # - {atom(), boolean(), ...} is encoded as {:open, [atom(), boolean()]} defp tuple_descr(tag, fields) do - case tuple_descr(fields, [], [], false, true) do - :empty -> - %{} + {static_fields, dynamic_fields, dynamic?, static_empty?} = + tuple_descr_fields(fields, [], [], false, false) - {static_fields, dynamic_fields, true, static_possible?} -> - dynamic_descr = %{tuple: tuple_new(tag, :lists.reverse(dynamic_fields))} + dynamic_descr = tuple_descr_static(tag, dynamic_fields) - if not static_possible? do - %{dynamic: dynamic_descr} - else - static_descr = %{tuple: tuple_new(tag, :lists.reverse(static_fields))} - Map.put(static_descr, :dynamic, dynamic_descr) - end - - {fields, _dynamic_fields, false, _static_possible?} -> - %{tuple: tuple_new(tag, :lists.reverse(fields))} + cond do + not dynamic? -> dynamic_descr + static_empty? -> %{dynamic: dynamic_descr} + true -> Map.put(tuple_descr_static(tag, static_fields), :dynamic, dynamic_descr) end end - defp tuple_descr([:term | rest], acc, dynamic_acc, dynamic?, static_possible?) do - tuple_descr(rest, [:term | acc], [:term | dynamic_acc], dynamic?, static_possible?) - end + defp tuple_descr_static(tag, fields), do: %{tuple: tuple_new(tag, :lists.reverse(fields))} - defp tuple_descr([value | rest], acc, dynamic_acc, dynamic?, static_possible?) do + defp tuple_descr_fields([value | rest], acc, dynamic_acc, dynamic?, static_empty?) do {dynamic_value, static_value} = pop_dynamic(value) - if empty?(dynamic_value) do - :empty - else - tuple_descr( - rest, - [static_value | acc], - [dynamic_value | dynamic_acc], - dynamic? or dynamic_value != static_value, - static_possible? and not empty?(static_value) - ) - end + tuple_descr_fields( + rest, + [static_value | acc], + [dynamic_value | dynamic_acc], + dynamic? or gradual?(value), + static_empty? or static_value == @none + ) end - defp tuple_descr([], acc, dynamic_acc, dynamic?, static_possible?) do - {acc, dynamic_acc, dynamic?, static_possible?} + defp tuple_descr_fields([], acc, dynamic_acc, dynamic?, static_empty?) do + {acc, dynamic_acc, dynamic?, static_empty?} end defp tuple_new(tag, elements), do: bdd_leaf_new(tag, elements) @@ -5040,7 +4956,14 @@ defmodule Module.Types.Descr do end end - defp tuple_literal_intersection(:open, [], tag, elements), do: {tag, elements} + # Detecting tuples built with none() fields + defp tuple_literal_intersection(:open, [], tag, elements) do + if Enum.any?(elements, &empty?/1) do + :empty + else + {tag, elements} + end + end defp tuple_literal_intersection(tag1, elements1, tag2, elements2) do case tuple_sizes_strategy(tag1, length(elements1), tag2, length(elements2)) do @@ -5679,7 +5602,7 @@ defmodule Module.Types.Descr do def tuple_values(descr) do case :maps.take(:dynamic, descr) do :error -> - if tuple_only?(descr) do + if non_empty_tuple_only?(descr) do process_tuples_values(Map.get(descr, :tuple, :bdd_bot)) else :badtuple @@ -5725,7 +5648,7 @@ defmodule Module.Types.Descr 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 tuple_only?(descr) + 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 @@ -5794,7 +5717,7 @@ defmodule Module.Types.Descr 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 tuple_only?(descr) + is_proper_tuple? = descr_key?(descr, :tuple) and non_empty_tuple_only?(descr) is_proper_size? = index == 0 or tuple_of_size_at_least_static?(descr, index) cond do diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index 54f756840ae..5342b510e0b 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -824,37 +824,28 @@ defmodule Module.Types.DescrTest do assert union(open_map(a: not_set()), t1) == t2 end - test "tuple preserves static part of gradual elements" do - x = union(atom([:ok]), dynamic(integer())) - - assert tuple([x, atom([:x])]) == %{ - tuple: {:closed, [atom([:ok]), atom([:x])]}, - dynamic: %{tuple: {:closed, [upper_bound(x), atom([:x])]}} - } - - assert subtype?(tuple([atom([:ok]), atom([:x])]), tuple([x, atom([:x])])) - end - - test "closed_map preserves static part of gradual values" do - x = union(atom([:ok]), dynamic(integer())) - - assert closed_map(a: x) == %{ - map: {:closed, [a: atom([:ok])]}, - dynamic: %{map: {:closed, [a: upper_bound(x)]}} - } - - assert subtype?(closed_map(a: atom([:ok])), closed_map(a: x)) - end - - test "non_empty_list preserves static part of gradual head types" do - x = union(atom([:ok]), dynamic(integer())) - - assert non_empty_list(x) == %{ - list: {atom([:ok]), empty_list()}, - dynamic: %{list: {upper_bound(x), empty_list()}} - } - - assert subtype?(non_empty_list(atom([:ok])), non_empty_list(x)) + test "structural types preserve static part of gradual elements" do + static = atom([:ok]) + gradual = union(static, dynamic(integer())) + upper_bound = upper_bound(gradual) + x = atom([:x]) + head = atom([:head]) + + for {descr, static_descr, dynamic_descr} <- [ + {tuple([gradual, x]), tuple([static, x]), tuple([upper_bound, x])}, + {open_tuple([gradual]), open_tuple([static]), open_tuple([upper_bound])}, + {closed_map(a: gradual), closed_map(a: static), closed_map(a: upper_bound)}, + {open_map(a: gradual), open_map(a: static), open_map(a: upper_bound)}, + {closed_map([{domain_key(:integer), gradual}]), + closed_map([{domain_key(:integer), static}]), + closed_map([{domain_key(:integer), upper_bound}])}, + {non_empty_list(gradual), non_empty_list(static), non_empty_list(upper_bound)}, + {non_empty_list(head, gradual), non_empty_list(head, static), + non_empty_list(head, upper_bound)} + ] do + assert descr == Map.put(static_descr, :dynamic, dynamic_descr) + assert subtype?(static_descr, descr) + end end end @@ -1708,6 +1699,7 @@ defmodule Module.Types.DescrTest do assert tuple_fetch(dynamic(empty_tuple()), 0) == :badindex assert tuple_fetch(dynamic(tuple([integer(), atom()])), 2) == :badindex assert tuple_fetch(union(dynamic(), integer()), 0) == :badtuple + assert tuple_fetch(tuple([none()]), 0) == :badtuple assert tuple_fetch(dynamic(tuple()), 0) |> Kernel.then(fn {opt, type} -> opt and equal?(type, dynamic()) end) @@ -1773,6 +1765,7 @@ defmodule Module.Types.DescrTest do assert tuple_insert_at(tuple([integer(), atom()]), -1, boolean()) == :badindex assert tuple_insert_at(integer(), 0, boolean()) == :badtuple assert tuple_insert_at(term(), 0, boolean()) == :badtuple + assert tuple_insert_at(tuple([none()]), 0, boolean()) == :badtuple # Out-of-bounds in a union assert union(tuple([integer(), atom()]), tuple([float()])) @@ -2944,6 +2937,7 @@ defmodule Module.Types.DescrTest do end test "list" do + assert non_empty_list(none()) |> to_quoted_string() == "none()" assert list(term()) |> to_quoted_string() == "list(term())" assert list(integer()) |> to_quoted_string() == "list(integer())" diff --git a/lib/elixir/test/elixir/module/types/map_test.exs b/lib/elixir/test/elixir/module/types/map_test.exs index 07a6fb2e955..9b05f097b3f 100644 --- a/lib/elixir/test/elixir/module/types/map_test.exs +++ b/lib/elixir/test/elixir/module/types/map_test.exs @@ -623,6 +623,8 @@ defmodule Module.Types.MapTest do ]) ) + map = closed_map([{domain_key(:integer), atom([:before])}]) + assert typecheck!( [x], ( @@ -632,16 +634,8 @@ defmodule Module.Types.MapTest do ) |> equal?( union( - tuple([ - atom([:before]), - closed_map([{domain_key(:integer), atom([:before])}]) - ]), - dynamic( - tuple([ - atom([:before, :after]), - closed_map([{domain_key(:integer), atom([:before])}]) - ]) - ) + tuple([atom([:before]), map]), + dynamic(tuple([atom([:before, :after]), map])) ) ) end From 2f2ad5c1e6ace324f3376442b5de73725709d6da Mon Sep 17 00:00:00 2001 From: Guillaume Duboc <27832828+gldubc@users.noreply.github.com> Date: Wed, 13 May 2026 11:35:00 +0200 Subject: [PATCH 3/3] Add additional emptiness checks for tuple elements --- lib/elixir/lib/module/types/descr.ex | 9 +++++++-- .../test/elixir/module/types/descr_test.exs | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index 15b3e588f87..fa2055cd500 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -4990,8 +4990,13 @@ defmodule Module.Types.Descr do defp tuple_sizes_strategy(_, _, _, _), do: :none # Intersects two lists of types, and _appends_ the extra elements to the result. - defp zip_non_empty_intersection!([], types2, acc), do: Enum.reverse(acc, types2) - defp zip_non_empty_intersection!(types1, [], acc), do: Enum.reverse(acc, types1) + defp zip_non_empty_intersection!([], types2, acc) do + if Enum.any?(types2, &empty?/1), do: throw(:empty), else: Enum.reverse(acc, types2) + end + + defp zip_non_empty_intersection!(types1, [], acc) do + if Enum.any?(types1, &empty?/1), do: throw(:empty), else: Enum.reverse(acc, types1) + end defp zip_non_empty_intersection!([type1 | rest1], [type2 | rest2], acc) do zip_non_empty_intersection!(rest1, rest2, [non_empty_intersection!(type1, type2) | acc]) diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index 5342b510e0b..b8bd2d2f37c 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -292,6 +292,21 @@ defmodule Module.Types.DescrTest do assert intersection(tuple([term(), integer()]), tuple([atom(), term()])) |> equal?(tuple([atom(), integer()])) + + empty_field = + closed_map(key: atom([:value])) + |> difference(open_map(key: atom(), optional: if_set(atom()))) + + assert empty?(empty_field) + refute empty_field == none() + + assert intersection(open_tuple([integer()]), tuple([integer(), empty_field])) + |> equal?(none()) + + assert intersection(tuple([integer(), empty_field]), open_tuple([integer()])) + |> equal?(none()) + + assert intersection(tuple(), tuple([integer(), empty_field])) |> equal?(none()) end test "map" do