From 4a860bb1023a23032ea4064d9d685ff926aff296 Mon Sep 17 00:00:00 2001 From: Dorian Villet Date: Thu, 2 Jul 2026 19:24:39 +0200 Subject: [PATCH] Do not flatten template benevolent unions in TypeCombinator::union() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "transform A | (B | C) to A | B | C" loop spliced the members of any BenevolentUnionType into the outer union, including TemplateBenevolentUnionType — discarding the template before the TemplateType guard (which already protects TemplateUnionType) could apply. Resolving a PHPDoc union like `K|null` where `K of array-key` therefore degraded the template to its bound at parse time. The reconstruction path only restored the template when all resulting union members came from the benevolent union, which fails as soon as another member (e.g. `null`) is present. Exclude TemplateType from the benevolent flattening branch so TemplateBenevolentUnionType falls through to the same guard as TemplateUnionType, mirroring how intersect() already handles templates before benevolent flattening (57e3cbf90). The template-rewrapping machinery in the reconstruction path becomes unreachable and is removed; plain benevolent unions behave exactly as before. Co-Authored-By: Claude Fable 5 --- src/Type/TypeCombinator.php | 10 +-- tests/PHPStan/Analyser/nsrt/bug-7279.php | 104 +++++++++++++++++++++++ 2 files changed, 105 insertions(+), 9 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-7279.php diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 00b298e8c7..1858e1de45 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -199,7 +199,6 @@ public static function union(Type ...$types): Type $alreadyNormalizedCounter = 0; $benevolentTypes = []; - $benevolentUnionObject = null; $neverCount = 0; // transform A | (B | C) to A | B | C for ($i = 0; $i < $typesCount; $i++) { @@ -215,10 +214,7 @@ public static function union(Type ...$types): Type $neverCount++; continue; } - if ($types[$i] instanceof BenevolentUnionType) { - if ($types[$i] instanceof TemplateBenevolentUnionType && $benevolentUnionObject === null) { - $benevolentUnionObject = $types[$i]; - } + if ($types[$i] instanceof BenevolentUnionType && !$types[$i] instanceof TemplateType) { $benevolentTypesCount = 0; $typesInner = $types[$i]->getTypes(); foreach ($typesInner as $benevolentInnerType) { @@ -471,10 +467,6 @@ public static function union(Type ...$types): Type } if ($tempTypes === []) { - if ($benevolentUnionObject instanceof TemplateBenevolentUnionType) { - return $benevolentUnionObject->withTypes(array_values($types)); - } - return new BenevolentUnionType(array_values($types), true); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-7279.php b/tests/PHPStan/Analyser/nsrt/bug-7279.php new file mode 100644 index 0000000000..3c1bfd5b96 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-7279.php @@ -0,0 +1,104 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug7279; + +use function PHPStan\Testing\assertType; + +/** + * @template T + */ +class Timeline +{ + +} + +class Percentage +{ + +} + +/** + * @template K of array-key + * @template T + * + * @param array $array + * @param (callable(T, K): bool) $fn + * + * @return ($array is non-empty-array ? T|null : null) + */ +function find(array $array, callable $fn): mixed +{ + foreach ($array as $key => $value) { + if ($fn($value, $key)) { + return $value; + } + } + + return null; +} + +/** + * @template K of array-key + * @template T + * + * @param array $array + * @param (callable(T, K): bool) $fn + * + * @return ($array is non-empty-array ? K|null : null) + */ +function findKey(array $array, callable $fn): string|int|null +{ + foreach ($array as $key => $value) { + if ($fn($value, $key)) { + return $key; + } + } + + return null; +} + +/** + * @param callable(mixed): bool $callback + * @param array $emptyList + * @param array{} $emptyMap + * @param array $unknownList + * @param array{id?: int, name?: string} $unknownMap + * @param non-empty-array> $nonEmptyList + * @param array{work: Timeline} $nonEmptyMap + */ +function assertions( + callable $callback, + array $emptyList, + array $emptyMap, + array $unknownList, + array $unknownMap, + array $nonEmptyList, + array $nonEmptyMap, +): void +{ + // Everything works great for find() + + assertType('null', find([], $callback)); + assertType('null', find($emptyList, $callback)); + assertType('null', find($emptyMap, $callback)); + + assertType('string|null', find($unknownList, $callback)); + assertType('int|string|null', find($unknownMap, $callback)); + + assertType('Bug7279\Timeline|null', find($nonEmptyList, $callback)); + assertType('Bug7279\Timeline|null', find($nonEmptyMap, $callback)); + + // But everything goes to hell for findKey() ?!? + + assertType('null', findKey([], $callback)); + assertType('null', findKey($emptyList, $callback)); + assertType('null', findKey($emptyMap, $callback)); + + assertType('int|null', findKey($unknownList, $callback)); + assertType("'id'|'name'|null", findKey($unknownMap, $callback)); + + assertType('int|null', findKey($nonEmptyList, $callback)); + assertType("'work'|null", findKey($nonEmptyMap, $callback)); +}