Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Project tagged-union narrowings through OR and foreach destructure#5531

Merged
ondrejmirtes merged 2 commits into
2.1.xfrom
tagged-union-preserve
Apr 25, 2026
Merged

Project tagged-union narrowings through OR and foreach destructure#5531
ondrejmirtes merged 2 commits into
2.1.xfrom
tagged-union-preserve

Conversation

@ondrejmirtes
Copy link
Copy Markdown
Member

@ondrejmirtes ondrejmirtes commented Apr 25, 2026

Summary

Two related precision improvements that build on the existing
conditional-expression-holder machinery. They share a worktree but are
independent — the second commit doesn't depend on the first.

1. Project stored-boolean narrowings through || truthy

if (A || B) truthy where A and B are stored booleans
(\$isA = \$obj instanceof ClassA; / \$isB = \$obj instanceof ClassB;)
left the held narrowing of \$obj invisible inside the OR — each arm's
specifyTypesInCondition only narrows the boolean variable itself, so
\$obj stayed mixed until a nested check pinned one of the booleans
down.

For each conditional-holder target we now resolve its type in the
left-truthy and right-truthy filtered scopes, and when both narrow it
strictly below the original we add a sure type carrying the union.
Gated so that certainty is preserved (e.g. if (empty(\$a['bar']))
correctly leaves \$a Maybe-defined).

2. 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 — array<array{null, int}|array{int, null}>, array<array{tag: 'a', data: int}|array{tag: 'b', data: string}>, etc. — used to lose the per-variant link between the
destructured variables: each one became the position-wise union
(`int|null`) and if (\$x === null) no longer narrowed \$y.

In enterForeach we now register conditional-expression holders on
each destructured variable: per 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 fires the
matching holder. Handles flat positional and string/int-keyed List_
patterns; nested destructure falls back to per-variable tracking. The
holders integrate with the existing reassignment invalidation, so
\$x = null; inside the body severs the binding for \$x only.

Test plan

  • New NSRT or-of-stored-booleans-narrowing.php covers two-/three-way
    instanceof OR, plus a mixed-narrowing-kind variant where only one
    arm narrows each target.
  • New NSRT foreach-destructure-tagged-union.php covers two- and
    three-variant unions with positional, named-key ('tag' /
    'data'), and constant-int discriminators, plus a single-variant
    no-op case and a reassignment-invalidation case.
  • tests/PHPStan/Analyser/NodeScopeResolverTest.php: 1542/1542.
  • tests/PHPStan/Analyser/ + tests/PHPStan/Rules/ + tests/PHPStan/Type/: 8231 tests, 59 pre-existing skips, 0 failures.
  • make phpstan: green.
  • No existing tests required updating — both changes are purely
    additive.

🤖 Generated with Claude Code

Closes phpstan/phpstan#9519

ondrejmirtes and others added 2 commits April 25, 2026 12:53
`if (A || B)` truthy where `A` and `B` are booleans backed by stored
conditional-expression holders (e.g. `$isA = $obj instanceof ClassA;
$isB = $obj instanceof ClassB;`) needs to apply the held narrowings of
the *target* (`$obj`) to the OR-truthy scope as the union of the per-arm
narrowings. specifyTypesInCondition for each arm only sees the boolean
variable itself, so the `$obj` narrowing was invisible until a later
inner check pinned one of the booleans down.

For each conditional-holder target we now resolve its type in the
left-truthy and right-truthy filtered scopes, and when both narrow it
strictly below the original we add a sure type carrying the union to
the OR-truthy result.

Gated to keep certainty unchanged: only project when the target stays
Yes-defined in all three scopes — `if (empty($a['bar']))` for instance
must leave `$a` Maybe-defined because `empty()` tolerates undefined
offsets.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
`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) <[email protected]>
@ondrejmirtes ondrejmirtes force-pushed the tagged-union-preserve branch from 3316920 to 79a31cf Compare April 25, 2026 10:54
@ondrejmirtes ondrejmirtes merged commit 73dae21 into 2.1.x Apr 25, 2026
353 of 356 checks passed
@ondrejmirtes ondrejmirtes deleted the tagged-union-preserve branch April 25, 2026 10:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant