Skip to content

Commit a5bd8b4

Browse files
gnutixclaude
andcommitted
Do not flatten template benevolent unions in TypeCombinator::union()
The "transform A | (B | C) to A | B | C" loop spliced the members of any BenevolentUnionType into the outer union, including TemplateBenevolentUnionType — destroying 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 (57e3cbf). 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 <noreply@anthropic.com>
1 parent 411daa4 commit a5bd8b4

2 files changed

Lines changed: 105 additions & 9 deletions

File tree

‎src/Type/TypeCombinator.php‎

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,6 @@ public static function union(Type ...$types): Type
199199
$alreadyNormalizedCounter = 0;
200200

201201
$benevolentTypes = [];
202-
$benevolentUnionObject = null;
203202
$neverCount = 0;
204203
// transform A | (B | C) to A | B | C
205204
for ($i = 0; $i < $typesCount; $i++) {
@@ -215,10 +214,7 @@ public static function union(Type ...$types): Type
215214
$neverCount++;
216215
continue;
217216
}
218-
if ($types[$i] instanceof BenevolentUnionType) {
219-
if ($types[$i] instanceof TemplateBenevolentUnionType && $benevolentUnionObject === null) {
220-
$benevolentUnionObject = $types[$i];
221-
}
217+
if ($types[$i] instanceof BenevolentUnionType && !$types[$i] instanceof TemplateType) {
222218
$benevolentTypesCount = 0;
223219
$typesInner = $types[$i]->getTypes();
224220
foreach ($typesInner as $benevolentInnerType) {
@@ -471,10 +467,6 @@ public static function union(Type ...$types): Type
471467
}
472468

473469
if ($tempTypes === []) {
474-
if ($benevolentUnionObject instanceof TemplateBenevolentUnionType) {
475-
return $benevolentUnionObject->withTypes(array_values($types));
476-
}
477-
478470
return new BenevolentUnionType(array_values($types), true);
479471
}
480472
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<?php // lint >= 8.0
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug7279;
6+
7+
use function PHPStan\Testing\assertType;
8+
9+
/**
10+
* @template T
11+
*/
12+
class Timeline
13+
{
14+
15+
}
16+
17+
class Percentage
18+
{
19+
20+
}
21+
22+
/**
23+
* @template K of array-key
24+
* @template T
25+
*
26+
* @param array<K, T> $array
27+
* @param (callable(T, K): bool) $fn
28+
*
29+
* @return ($array is non-empty-array ? T|null : null)
30+
*/
31+
function find(array $array, callable $fn): mixed
32+
{
33+
foreach ($array as $key => $value) {
34+
if ($fn($value, $key)) {
35+
return $value;
36+
}
37+
}
38+
39+
return null;
40+
}
41+
42+
/**
43+
* @template K of array-key
44+
* @template T
45+
*
46+
* @param array<K, T> $array
47+
* @param (callable(T, K): bool) $fn
48+
*
49+
* @return ($array is non-empty-array ? K|null : null)
50+
*/
51+
function findKey(array $array, callable $fn): string|int|null
52+
{
53+
foreach ($array as $key => $value) {
54+
if ($fn($value, $key)) {
55+
return $key;
56+
}
57+
}
58+
59+
return null;
60+
}
61+
62+
/**
63+
* @param callable(mixed): bool $callback
64+
* @param array<never, never> $emptyList
65+
* @param array{} $emptyMap
66+
* @param array<int, string> $unknownList
67+
* @param array{id?: int, name?: string} $unknownMap
68+
* @param non-empty-array<int, Timeline<Percentage>> $nonEmptyList
69+
* @param array{work: Timeline<Percentage>} $nonEmptyMap
70+
*/
71+
function assertions(
72+
callable $callback,
73+
array $emptyList,
74+
array $emptyMap,
75+
array $unknownList,
76+
array $unknownMap,
77+
array $nonEmptyList,
78+
array $nonEmptyMap,
79+
): void
80+
{
81+
// Everything works great for find()
82+
83+
assertType('null', find([], $callback));
84+
assertType('null', find($emptyList, $callback));
85+
assertType('null', find($emptyMap, $callback));
86+
87+
assertType('string|null', find($unknownList, $callback));
88+
assertType('int|string|null', find($unknownMap, $callback));
89+
90+
assertType('Bug7279\Timeline<Bug7279\Percentage>|null', find($nonEmptyList, $callback));
91+
assertType('Bug7279\Timeline<Bug7279\Percentage>|null', find($nonEmptyMap, $callback));
92+
93+
// But everything goes to hell for findKey() ?!?
94+
95+
assertType('null', findKey([], $callback));
96+
assertType('null', findKey($emptyList, $callback));
97+
assertType('null', findKey($emptyMap, $callback));
98+
99+
assertType('int|null', findKey($unknownList, $callback));
100+
assertType("'id'|'name'|null", findKey($unknownMap, $callback));
101+
102+
assertType('int|null', findKey($nonEmptyList, $callback));
103+
assertType("'work'|null", findKey($nonEmptyMap, $callback));
104+
}

0 commit comments

Comments
 (0)