Skip to content

Commit 6c1cfce

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 6c1cfce

2 files changed

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

0 commit comments

Comments
 (0)