Fix phpstan/phpstan#10055: Conditional argument type not properly narrowing type (for constant string unions?)#5386
Conversation
VincentLanglet
left a comment
There was a problem hiding this comment.
You say
Later, when matching conditions in a match arm where $param1 is narrowed to 'value1', the equals() check compares ConstantStringType('value1') against UnionType('value1'|'value2'), which always fails.
Rather splitting the Union here, shouldn't the check "later" be improve to also supports isSuperType sometimes ?
|
Done. Here's a summary of what was changed to address the reviewer's feedback: Reviewer's concern: Instead of splitting union types into individual What was done:
Why the flag is needed: Using |
VincentLanglet
left a comment
There was a problem hiding this comment.
There is no need for a useSubtypeForConditionMatching param, and this shouldn't be dependant to the situation (match, if, etc) but more with the type.
Maybe finiteTypes or something like this ?
Also explain more in detailed why this doesn't work for youur mixed~null example.
|
The changes have been pushed. Here's a summary of what was done to address the reviewer's feedback: What the reviewer asked for:
What was implemented: Removed the flag entirely and replaced it with a two-pass matching approach in
Why a simple Using The two-pass approach solves this: exact matches (Pass 1) take priority, preventing broader conditions from interfering. Pass 2 only activates when no exact match exists, which is exactly the case for conditional parameter types with union conditions. Additional improvement: The bug-5051 test expectations were updated to reflect more precise type narrowing that results from the Pass 2 fallback matching. |
21b91c0 to
6f358cd
Compare
VincentLanglet
left a comment
There was a problem hiding this comment.
Explains why the check
if (count($conditionalTypeHolder->getType()->getFiniteTypes()) === 0) {
continue 2;
}
is needed ?
We could think that it should work for more/every type,
Like
- If is_int|is_string , then a condition if is_int should match
- If i > 2, then a condition i > 3 should match
So why can't we just remove the finiteType condition ?
|
Pushed. Here's a summary of the changes to address the reviewer's question: The reviewer asked why the I verified the answer empirically by removing the check and running the test suite — 2 concrete regressions confirmed the check is necessary:
The root cause: conditional expression holders come from 4 different sources — only source (1) conditional parameter types and (5) foreach constant arrays produce finite condition types. Sources 2-4 (scope merging, assignment handlers, TypeSpecifier boolean processing) produce non-finite types that are too broad for I added a detailed comment in the code explaining all of this. |
VincentLanglet
left a comment
There was a problem hiding this comment.
But we should have
function test(mixed $foo): void
{
$a = 0;
if (\is_string($foo) || \is_int($foo)) {
$a = 1;
}
if (\is_int($foo)) {
assertType('1', $a);
}
}
in which case, are you with this example ; 1, 2, 3, 4 or 5 ?
|
Pushed. Here's a summary of the changes made to address the reviewer's feedback: Reviewer's example: A scope-merging case where What was changed: Replaced the The
Union types explicitly enumerate alternatives, making Test added: Verification: |
VincentLanglet
left a comment
There was a problem hiding this comment.
Remove the check
if (!$conditionalTypeHolder->getType() instanceof UnionType) {
continue 2;
}
and consider the needed fix is somewhere else.
For bug-14249 the getMixed method should be marked with
/** @phpstan-impure */
For pr-5379 file maybe some conditinal expression are wrongly added somewhere else ?
A minimal reproducer is
/**
* @param array<string, mixed> $aggregation
* @param non-falsy-string $type
*/
private function parseAggregation(array $aggregation, string $type)
{
if (empty($aggregation['field']) && $type !== 'filter') {
return null;
}
if ($type !== 'filter') {
assertType("array<string, mixed>", $aggregation);
}
assertType('non-falsy-string', $type);
}
which ends with some weird condition
$aggregation=array<string, mixed> => 'filter' (Yes)
$type=non-falsy-string => non-empty-array<string, mixed>&hasOffset('field') (Yes)
``
added in the BooleanAndHandler
|
All three commits have been pushed. Here's a summary of the changes: Commit 1: Skip trivially-always-true conditions in
Commit 2: Mark
Commit 3: Remove
All 11840 tests pass (79348 assertions) and |
VincentLanglet
left a comment
There was a problem hiding this comment.
Add a non regression test for the change made in src/Analyser/TypeSpecifier.php which failed before even without the change in the MutatingScope.
|
Done. Added a non-regression test All tests pass (11840 tests, 79348 assertions) and |
VincentLanglet
left a comment
There was a problem hiding this comment.
Is there other TypeCombinator::remove call to fix the same way ?
|
Done. Here's a summary of the analysis and change: The reviewer asked: Are there other Analysis of all
What was fixed: The symmetric counterpart The result-side |
VincentLanglet
left a comment
There was a problem hiding this comment.
Add a non regression test for the latest fix
|
Done. Added a non-regression test The test uses I verified the test would fail without the fix by temporarily reverting it — |
VincentLanglet
left a comment
There was a problem hiding this comment.
Your test is weird cause we should already have
/**
* @param non-falsy-string $type
* @param int|string $value
*/
function testNotSureTriviallyTrueConditionSkipped(string $type, int|string $value): void
{
if (is_string($type) && is_int($value)) {
return;
}
assertType('string', $value);
}
since is_string($type) is a useless condition
|
Pushed. Here's a summary of what was changed to address the reviewer's feedback: Reviewer's concern: The test What was done:
Why |
642f06f to
2d5f0f6
Compare
- Split union condition types into individual ConditionalExpressionHolders - When TypeCombinator::intersect or ::remove produces a UnionType, each member gets its own holder so the equals() check can match individual constant types - New regression test in tests/PHPStan/Analyser/nsrt/bug-10055.php
Instead of splitting union condition types into individual ConditionalExpressionHolder instances, use isSuperTypeOf at the matching site so that a narrowed type (e.g. 'value1') correctly matches a broader condition type (e.g. 'value1'|'value2'). A new useSubtypeForConditionMatching flag on ConditionalExpressionHolder limits this relaxed matching to conditional parameter type holders only. Other holders (from scope merging, assignments, type specifying) keep strict equals() matching to avoid cascading side effects. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tead
Instead of a flag on ConditionalExpressionHolder to control whether
condition matching uses equals() or isSuperTypeOf(), use a two-pass
approach in filterBySpecifiedTypes():
Pass 1: exact matching via equals() (existing behavior)
Pass 2: isSuperTypeOf for condition types with finite types (fallback)
Pass 2 only runs when pass 1 found no matches for a target expression.
This handles conditional parameter types where the condition is a union
(e.g. 'value1'|'value2') that can't match a narrowed type ('value1')
via equals(), but should match via isSuperTypeOf.
The two-pass approach avoids regressions from using isSuperTypeOf
globally: when scope merging creates both a specific condition (e.g.
"if $key=2, then $value is Yes") and a broader condition (e.g. "if
$key=0|2, then $value is Maybe"), exact matching in pass 1 prevents
the broader condition from degrading variable certainty through
extremeIdentity.
The bug-5051 test expectations are updated to reflect more precise
type narrowing: when narrowing $data to a specific constant, PHPStan
now correctly determines which branch was taken and narrows related
variables accordingly (e.g. $update becomes 'false' instead of 'bool'
when $data === 1 because that branch always sets $update = false).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…hing Address reviewer question about why isSuperTypeOf cannot be used for all types. Conditional expression holders come from multiple sources (param conditional types, scope merging, assignment handlers, TypeSpecifier boolean processing). Non-finite condition types from scope merging and assignment handlers are too broad for isSuperTypeOf matching — e.g. non-falsy-string incorrectly matches 'filter', or mixed~false matches false, causing conflicting conditional expressions to produce *NEVER*. The finite types check restricts Pass 2 to closed sets of concrete values where subtype matching is safe. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…atching Replace the getFiniteTypes() guard with an instanceof UnionType check in Pass 2 of filterBySpecifiedTypes(). This broadens Pass 2 to handle all union condition types (not just finite ones), which fixes cases where scope merging creates union conditions like int|string that should match a narrowed type like int via isSuperTypeOf. The UnionType guard still prevents regressions from non-union condition types (non-falsy-string from TypeSpecifier, mixed~false from assignment handlers) that are too broad for isSuperTypeOf matching. Added test case for scope merging with is_string()||is_int() narrowing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…lTypes When TypeCombinator::remove(scopeType, sureType) returns a type equal to the scope type, the condition is trivially always true and encodes no meaningful information. Such conditions occur when the sure type cannot be meaningfully subtracted from the scope type (e.g. removing a constant string from non-falsy-string, or removing an intersection type from a broad array type). These trivial conditions caused regressions when Pass 2 used isSuperTypeOf for condition matching, because the condition type (being the same as the scope type) is a supertype of any narrowed version, making the conditional expression always activate incorrectly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The UnionType guard was a workaround for two issues that are now fixed at their root causes: 1. pr-5379 regression (non-falsy-string matching 'filter'): Fixed by skipping trivially-always-true conditions in processBooleanSureConditionalTypes — when TypeCombinator::remove has no effect, the condition is not created. 2. bug-14249 regression (mixed~false producing *NEVER*): Fixed by marking the getMixed() test helper as @phpstan-impure, preventing conflicting conditional expressions from assignment handlers. With these root causes addressed, Pass 2 can safely use isSuperTypeOf for all condition types without restriction. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2d5f0f6 to
7935f44
Compare
Summary
Conditional parameter types (
@param ($param1 is 'value3' ? bool : int) $param2) fail to narrow correctly when the parameter union type has more than 2 members. The "false" branch condition becomes a UnionType that never matches individual constant types during theequals()check.Changes
src/Analyser/MutatingScope.phpto split union condition types into individualConditionalExpressionHolderinstances when creating holders for@paramconditional typestests/PHPStan/Analyser/nsrt/bug-10055.phpRoot cause
When processing
@param ($param1 is 'value3' ? bool : int) $param2with$param1typed as'value1'|'value2'|'value3':TypeCombinator::intersect('value1'|'value2'|'value3', 'value3')='value3'(works fine)TypeCombinator::remove('value1'|'value2'|'value3', 'value3')='value1'|'value2'(a UnionType)Later, when matching conditions in a
matcharm where$param1is narrowed to'value1', theequals()check comparesConstantStringType('value1')againstUnionType('value1'|'value2'), which always fails.The fix splits union condition types into individual holders: instead of one holder with condition
'value1'|'value2', two holders are created — one for'value1'and one for'value2'— both mapping to the same result type (int). This allows theequals()check to match correctly.Test
Added
tests/PHPStan/Analyser/nsrt/bug-10055.phpwhich verifies that in a match expression, conditional parameter types correctly narrow$param2tointfor'value1'and'value2'arms, and toboolfor the'value3'arm.Fixes phpstan/phpstan#10055