Skip to content

Commit 73dae21

Browse files
ondrejmirtesclaude
andcommitted
Track destructure relationships across tagged-union foreach
`foreach ($a as [$x, $y, …])` over an iterable whose value type is a tagged union of constant arrays (e.g. `array<array{null, int}|array{int, null}>`) loses the per-variant link between the destructured variables: each one ends up as the position's union (`int|null`) and `if ($x === null)` doesn't narrow `$y` even though every variant pairs the two. Recover the link in `enterForeach` by registering conditional-expression holders on each destructured variable: for every variant, "when this variable matches the variant's value at its position, the *other* variables match the variant's values at their positions". A later narrowing on any one of them then fires the matching holder and pins the others to the corresponding variant's values. Handles flat positional and string-/int-keyed `List_` patterns where each item targets a plain Variable; nested destructure falls back to the existing per-variable type tracking. The held conditions integrate with the existing reassignment invalidation, so `$x = null;` inside the body severs the binding for `$x` only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 11dddc2 commit 73dae21

2 files changed

Lines changed: 252 additions & 0 deletions

File tree

src/Analyser/NodeScopeResolver.php

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@
141141
use PHPStan\TrinaryLogic;
142142
use PHPStan\Type\ArrayType;
143143
use PHPStan\Type\ClosureType;
144+
use PHPStan\Type\Constant\ConstantIntegerType;
145+
use PHPStan\Type\Constant\ConstantStringType;
144146
use PHPStan\Type\FileTypeMapper;
145147
use PHPStan\Type\Generic\TemplateTypeHelper;
146148
use PHPStan\Type\Generic\TemplateTypeMap;
@@ -4062,6 +4064,14 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto
40624064
)->getScope();
40634065
$vars = array_merge($vars, $this->getAssignedVariables($stmt->keyVar));
40644066
}
4067+
4068+
if ($stmt->valueVar instanceof List_) {
4069+
$scope = $this->addDestructureTaggedUnionConditionalHolders(
4070+
$scope,
4071+
$originalScope->getIterableValueType($iterateeType),
4072+
$stmt->valueVar,
4073+
);
4074+
}
40654075
}
40664076

40674077
$constantArrays = $iterateeType->getConstantArrays();
@@ -4120,6 +4130,115 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto
41204130
return $this->processVarAnnotation($scope, $vars, $stmt);
41214131
}
41224132

4133+
/**
4134+
* When destructuring an iterable whose value type is a tagged union of
4135+
* constant arrays — e.g. `array<array{null, int}|array{int, null}>` — the
4136+
* variants describe a relationship between the destructured variables that
4137+
* a per-variable narrowing would normally lose: knowing `$x === null` should
4138+
* imply `$y === int`, but `foreach ($a as [$x, $y])` assigns `$x` and `$y`
4139+
* independently, so each ends up as the union (`int|null`) and the link is
4140+
* dropped.
4141+
*
4142+
* Recover the link by storing conditional-expression holders on each
4143+
* destructured variable: for every variant, "when this variable matches the
4144+
* variant's value at its position, the other variables match the variant's
4145+
* values at their positions". A later `if ($x === null)` then fires the
4146+
* matching holder and narrows `$y` accordingly.
4147+
*
4148+
* Only handles flat positional / keyed destructure patterns (List_) where
4149+
* each item's target is a plain Variable; nested destructure is left for
4150+
* the regular per-variable type tracking.
4151+
*/
4152+
private function addDestructureTaggedUnionConditionalHolders(
4153+
MutatingScope $scope,
4154+
Type $iterableValueType,
4155+
List_ $list,
4156+
): MutatingScope
4157+
{
4158+
$constantArrays = $iterableValueType->getConstantArrays();
4159+
if (count($constantArrays) < 2) {
4160+
return $scope;
4161+
}
4162+
4163+
// Collect each list item's array-key value and target variable.
4164+
$items = [];
4165+
foreach ($list->items as $position => $item) {
4166+
if ($item === null) {
4167+
continue;
4168+
}
4169+
if (!$item->value instanceof Variable || !is_string($item->value->name)) {
4170+
return $scope;
4171+
}
4172+
if ($item->key === null) {
4173+
$keyValue = $position;
4174+
} elseif ($item->key instanceof Node\Scalar\String_) {
4175+
$keyValue = $item->key->value;
4176+
} elseif ($item->key instanceof Node\Scalar\Int_) {
4177+
$keyValue = $item->key->value;
4178+
} else {
4179+
return $scope;
4180+
}
4181+
$items[] = ['key' => $keyValue, 'name' => $item->value->name];
4182+
}
4183+
4184+
if (count($items) < 2) {
4185+
return $scope;
4186+
}
4187+
4188+
// For every variant, every item must have a matching key with a single
4189+
// value type at it; otherwise the variants don't all describe the same
4190+
// destructure shape and we can't form a sound holder set.
4191+
$variantValuesByItem = [];
4192+
foreach ($items as $itemIdx => $itemInfo) {
4193+
$variantValuesByItem[$itemIdx] = [];
4194+
foreach ($constantArrays as $variantIdx => $variant) {
4195+
$keyType = is_int($itemInfo['key']) ? new ConstantIntegerType($itemInfo['key']) : new ConstantStringType($itemInfo['key']);
4196+
if (!$variant->hasOffsetValueType($keyType)->yes()) {
4197+
return $scope;
4198+
}
4199+
$variantValuesByItem[$itemIdx][$variantIdx] = $variant->getOffsetValueType($keyType);
4200+
}
4201+
}
4202+
4203+
// For each item × variant, build a holder: "when item is variant's value
4204+
// at this position, the *other* items are the variant's values at their
4205+
// positions". Skip the variant if the condition value is too wide to be
4206+
// a useful discriminator (i.e. equal to the union of all the variant
4207+
// values at this position — narrowing it back wouldn't pick a variant).
4208+
foreach ($items as $itemIdx => $itemInfo) {
4209+
$exprString = '$' . $itemInfo['name'];
4210+
$variantConditionTypes = $variantValuesByItem[$itemIdx];
4211+
$itemUnionType = TypeCombinator::union(...array_values($variantConditionTypes));
4212+
$holders = [];
4213+
foreach (array_keys($constantArrays) as $variantIdx) {
4214+
$conditionType = $variantConditionTypes[$variantIdx];
4215+
if ($conditionType->equals($itemUnionType)) {
4216+
continue;
4217+
}
4218+
$conditions = [
4219+
$exprString => ExpressionTypeHolder::createYes(new Variable($itemInfo['name']), $conditionType),
4220+
];
4221+
foreach ($items as $otherIdx => $otherInfo) {
4222+
if ($otherIdx === $itemIdx) {
4223+
continue;
4224+
}
4225+
$otherType = $variantValuesByItem[$otherIdx][$variantIdx];
4226+
$holder = new ConditionalExpressionHolder(
4227+
$conditions,
4228+
ExpressionTypeHolder::createYes(new Variable($otherInfo['name']), $otherType),
4229+
);
4230+
$holders['$' . $otherInfo['name']][$holder->getKey()] = $holder;
4231+
}
4232+
}
4233+
4234+
foreach ($holders as $targetExprString => $targetHolders) {
4235+
$scope = $scope->addConditionalExpressions($targetExprString, $targetHolders);
4236+
}
4237+
}
4238+
4239+
return $scope;
4240+
}
4241+
41234242
/**
41244243
* @param callable(Node $node, Scope $scope): void $nodeCallback
41254244
*/
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
<?php
2+
3+
namespace ForeachDestructureTaggedUnion;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
class A {}
8+
class B {}
9+
10+
/**
11+
* @param list<array{null, int}|array{int, null}> $list
12+
*/
13+
function basicTwoVariants(array $list): void
14+
{
15+
foreach ($list as [$x, $y]) {
16+
assertType('int|null', $x);
17+
assertType('int|null', $y);
18+
19+
if ($x === null) {
20+
assertType('null', $x);
21+
assertType('int', $y);
22+
}
23+
if ($x !== null) {
24+
assertType('int', $x);
25+
assertType('null', $y);
26+
}
27+
if ($y === null) {
28+
assertType('int', $x);
29+
assertType('null', $y);
30+
}
31+
if ($y !== null) {
32+
assertType('null', $x);
33+
assertType('int', $y);
34+
}
35+
}
36+
}
37+
38+
/**
39+
* @param list<array{A, int}|array{B, string}> $list
40+
*/
41+
function classDiscriminator(array $list): void
42+
{
43+
foreach ($list as [$obj, $value]) {
44+
if ($obj instanceof A) {
45+
assertType('int', $value);
46+
}
47+
if ($obj instanceof B) {
48+
assertType('string', $value);
49+
}
50+
}
51+
}
52+
53+
/**
54+
* @param list<array{0, int}|array{1, string}|array{2, bool}> $list
55+
*/
56+
function threeVariants(array $list): void
57+
{
58+
foreach ($list as [$tag, $value]) {
59+
if ($tag === 0) {
60+
assertType('int', $value);
61+
}
62+
if ($tag === 1) {
63+
assertType('string', $value);
64+
}
65+
if ($tag === 2) {
66+
assertType('bool', $value);
67+
}
68+
}
69+
}
70+
71+
/**
72+
* @param list<array{tag: 'a', data: int}|array{tag: 'b', data: string}> $list
73+
*/
74+
function namedKeyDiscriminator(array $list): void
75+
{
76+
foreach ($list as ['tag' => $tag, 'data' => $data]) {
77+
if ($tag === 'a') {
78+
assertType('int', $data);
79+
}
80+
if ($tag === 'b') {
81+
assertType('string', $data);
82+
}
83+
}
84+
}
85+
86+
/**
87+
* Single-variant array — no tagged union, the per-variable narrowing applies
88+
* as before and the destructure-aware logic must be a no-op.
89+
*
90+
* @param list<array{int, string}> $list
91+
*/
92+
function singleVariant(array $list): void
93+
{
94+
foreach ($list as [$x, $y]) {
95+
assertType('int', $x);
96+
assertType('string', $y);
97+
}
98+
}
99+
100+
/**
101+
* Reassigning a destructured variable severs the destructure relationship
102+
* for that variable (PHPStan's existing invalidation handles this).
103+
*
104+
* @param list<array{null, int}|array{int, null}> $list
105+
*/
106+
function reassignmentInvalidates(array $list): void
107+
{
108+
foreach ($list as [$x, $y]) {
109+
$x = null;
110+
// $y is still int|null — the holder for $x was invalidated by reassignment.
111+
assertType('int|null', $y);
112+
}
113+
}
114+
115+
/**
116+
* Three-position tagged union — narrowing one position should pin the other
117+
* two for the matching variant.
118+
*
119+
* @param list<array{'a', int, true}|array{'b', string, false}> $list
120+
*/
121+
function threePositions(array $list): void
122+
{
123+
foreach ($list as [$tag, $value, $flag]) {
124+
if ($tag === 'a') {
125+
assertType('int', $value);
126+
assertType('true', $flag);
127+
}
128+
if ($tag === 'b') {
129+
assertType('string', $value);
130+
assertType('false', $flag);
131+
}
132+
}
133+
}

0 commit comments

Comments
 (0)