From a9f7ef7a792e32f970c39da138c24f3b103ff69e Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Wed, 24 Jun 2026 12:01:08 +0200 Subject: [PATCH 1/2] Thread full descr through fun_denormalize reduce Previously inner representation was threaded and returned, which broke expectations of caller `non_term_type_to_quoted` Fixes #15523 --- lib/elixir/lib/module/types/descr.ex | 29 +++++++++++++++---- .../test/elixir/module/types/descr_test.exs | 28 ++++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index 90e49b3772..d34e9d64e0 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -1929,14 +1929,20 @@ defmodule Module.Types.Descr do # representation. The goal here is to do the opposite of fun_descr # and put static and dynamic parts back together to improve # pretty printing. - defp fun_denormalize(%{fun: {:union, static_repr}}, %{fun: {:union, dynamic_repr}}, opts) do - # Denormalize each arity + defp fun_denormalize( + %{fun: {:union, static_repr}} = static, + %{fun: {:union, dynamic_repr}} = dynamic, + opts + ) do + # Denormalize each arity. The accumulator threads the full descrs (not the + # raw fun reprs), removing only the arities we successfully denormalize so + # the caller still receives valid descrs whose non-fun parts are preserved. for {arity, static_bdd} <- static_repr, {^arity, dynamic_bdd} <- dynamic_repr, - reduce: {static_repr, dynamic_repr, []} do + reduce: {static, dynamic, []} do {statics, dynamics, acc} -> with {:ok, quoted} <- fun_denormalize_arity(arity, static_bdd, dynamic_bdd, opts) do - {Map.delete(statics, arity), Map.delete(dynamics, arity), [quoted | acc]} + {fun_delete_arity(statics, arity), fun_delete_arity(dynamics, arity), [quoted | acc]} else _ -> {statics, dynamics, acc} end @@ -1944,10 +1950,21 @@ defmodule Module.Types.Descr do end # If not unions of functions, do not try to denormalize. - defp fun_denormalize(static_repr, dynamic_repr, _opts) do - {static_repr, dynamic_repr, []} + defp fun_denormalize(static, dynamic, _opts) do + {static, dynamic, []} + end + + # Removes `arity` from a descr's function union, dropping the :fun key entirely + # once no arities remain. + defp fun_delete_arity(%{fun: {:union, repr}} = descr, arity) do + case Map.delete(repr, arity) do + empty when empty == %{} -> Map.delete(descr, :fun) + repr -> %{descr | fun: {:union, repr}} + end end + defp fun_delete_arity(descr, _arity), do: descr + defp fun_denormalize_arity(arity, static_bdd, dynamic_bdd, opts) do static_pos = fun_bdd_to_pos_dnf(arity, static_bdd) dynamic_pos = fun_bdd_to_pos_dnf(arity, dynamic_bdd) diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index 78c8f252eb..cfad46c819 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -3689,6 +3689,34 @@ defmodule Module.Types.DescrTest do """ end + test "fun union of static and dynamic keeps non-fun components" do + # Denormalizing a static/dynamic fun union must not drop the other parts of + # the type, nor crash when an arity fails to denormalize. + + # (a) must not raise when denormalization fails for an arity + assert opt_difference(fun([term()], atom()), fun([integer()], dynamic(atom()))) + |> to_quoted_string() == + "dynamic((term() -> atom()) and (integer() -> atom()))" + + # (b) non-fun components must be preserved alongside the denormalized fun + assert opt_union( + integer(), + opt_union(fun([integer()], atom()), dynamic(fun([integer()], atom()))) + ) + |> to_quoted_string() == "(integer() -> atom()) or integer()" + + # several components, with a leftover non-denormalized dynamic arity + assert opt_union( + atom([:tag]), + opt_union( + fun([integer()], atom()), + dynamic(opt_union(fun([integer()], atom()), fun([integer(), integer()], atom()))) + ) + ) + |> to_quoted_string() == + "dynamic((integer(), integer() -> atom())) or :tag or (integer() -> atom())" + end + test "fun (negation)" do assert fun([integer()], atom()) |> opt_negation() |> to_quoted_string() == "not (integer() -> atom())" From 78529146fdb7ef116c21b51547a7c49ea46c9bd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Wed, 24 Jun 2026 12:25:08 +0200 Subject: [PATCH 2/2] Refactor function denormalization and arity handling --- lib/elixir/lib/module/types/descr.ex | 37 +++++++++++----------------- 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index d34e9d64e0..571a391ecb 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -1934,19 +1934,18 @@ defmodule Module.Types.Descr do %{fun: {:union, dynamic_repr}} = dynamic, opts ) do - # Denormalize each arity. The accumulator threads the full descrs (not the - # raw fun reprs), removing only the arities we successfully denormalize so - # the caller still receives valid descrs whose non-fun parts are preserved. - for {arity, static_bdd} <- static_repr, - {^arity, dynamic_bdd} <- dynamic_repr, - reduce: {static, dynamic, []} do - {statics, dynamics, acc} -> - with {:ok, quoted} <- fun_denormalize_arity(arity, static_bdd, dynamic_bdd, opts) do - {fun_delete_arity(statics, arity), fun_delete_arity(dynamics, arity), [quoted | acc]} - else - _ -> {statics, dynamics, acc} - end - end + {static_repr, dynamic_repr, acc} = + Enum.reduce(static_repr, {static_repr, dynamic_repr, []}, fn + {arity, static_bdd}, {statics, dynamics, acc} -> + with %{^arity => dynamic_bdd} <- dynamics, + {:ok, quoted} <- fun_denormalize_arity(arity, static_bdd, dynamic_bdd, opts) do + {Map.delete(statics, arity), Map.delete(dynamics, arity), [quoted | acc]} + else + _ -> {statics, dynamics, acc} + end + end) + + {fun_replace_arities(static, static_repr), fun_replace_arities(dynamic, dynamic_repr), acc} end # If not unions of functions, do not try to denormalize. @@ -1954,16 +1953,8 @@ defmodule Module.Types.Descr do {static, dynamic, []} end - # Removes `arity` from a descr's function union, dropping the :fun key entirely - # once no arities remain. - defp fun_delete_arity(%{fun: {:union, repr}} = descr, arity) do - case Map.delete(repr, arity) do - empty when empty == %{} -> Map.delete(descr, :fun) - repr -> %{descr | fun: {:union, repr}} - end - end - - defp fun_delete_arity(descr, _arity), do: descr + defp fun_replace_arities(descr, arities) when arities == %{}, do: Map.delete(descr, :fun) + defp fun_replace_arities(descr, arities), do: %{descr | fun: {:union, arities}} defp fun_denormalize_arity(arity, static_bdd, dynamic_bdd, opts) do static_pos = fun_bdd_to_pos_dnf(arity, static_bdd)