Skip to content

Fix phpstan/phpstan#10055: Conditional argument type not properly narrowing type (for constant string unions?)#5386

Open
phpstan-bot wants to merge 9 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-6ci6gsl
Open

Fix phpstan/phpstan#10055: Conditional argument type not properly narrowing type (for constant string unions?)#5386
phpstan-bot wants to merge 9 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-6ci6gsl

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

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 the equals() check.

Changes

  • Modified src/Analyser/MutatingScope.php to split union condition types into individual ConditionalExpressionHolder instances when creating holders for @param conditional types
  • Added regression test tests/PHPStan/Analyser/nsrt/bug-10055.php

Root cause

When processing @param ($param1 is 'value3' ? bool : int) $param2 with $param1 typed as 'value1'|'value2'|'value3':

  • The "true" branch condition is TypeCombinator::intersect('value1'|'value2'|'value3', 'value3') = 'value3' (works fine)
  • The "false" branch condition is TypeCombinator::remove('value1'|'value2'|'value3', 'value3') = 'value1'|'value2' (a UnionType)

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.

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 the equals() check to match correctly.

Test

Added tests/PHPStan/Analyser/nsrt/bug-10055.php which verifies that in a match expression, conditional parameter types correctly narrow $param2 to int for 'value1' and 'value2' arms, and to bool for the 'value3' arm.

Fixes phpstan/phpstan#10055

@VincentLanglet VincentLanglet self-assigned this Apr 4, 2026
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ?

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

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 ConditionalExpressionHolder instances, the matching logic "later" should use isSuperTypeOf instead of equals().

What was done:

  1. Reverted the union-splitting approach in MutatingScope::enterFunctionLike() — the condition types from TypeCombinator::intersect() and TypeCombinator::remove() are no longer decomposed into individual union members.

  2. Added useSubtypeForConditionMatching flag to ConditionalExpressionHolder — this flag controls whether condition matching uses strict equals() or relaxed isSuperTypeOf().

  3. Set the flag to true only for conditional parameter type holders (created from @param conditional types).

  4. Updated the matching logic in filterBySpecifiedTypes() — when the flag is true, uses isSuperTypeOf so that e.g. 'value1'|'value2' (condition type) matches 'value1' (narrowed type). Otherwise keeps strict equals().

Why the flag is needed: Using isSuperTypeOf globally causes regressions because conditional expression holders from assignment handlers and scope merging create conditions where strict matching is necessary. For example, assignment handlers create conditions like "if $v is mixed~null, then getMixed() is mixed~null" — using isSuperTypeOf here causes cascading matches that produce *NEVER* types.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

The changes have been pushed. Here's a summary of what was done to address the reviewer's feedback:

What the reviewer asked for:

  1. Remove the useSubtypeForConditionMatching flag from ConditionalExpressionHolder
  2. Make the behavior depend on the type (e.g., finiteTypes), not the call site
  3. Explain why isSuperTypeOf doesn't work globally

What was implemented:

Removed the flag entirely and replaced it with a two-pass matching approach in filterBySpecifiedTypes():

  • Pass 1: Exact matching via equals() (original behavior) - always runs first
  • Pass 2: isSuperTypeOf matching for condition types with finite types - only runs when Pass 1 found no matches for the target expression

Why a simple finiteTypes check alone causes regressions:

Using isSuperTypeOf for all finite-type conditions (without the two-pass priority) causes real regressions in variable definedness tracking. 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"), both would match via isSuperTypeOf. The extremeIdentity certainty calculation then degrades Yes AND Maybe = Maybe, causing false "Variable might not be defined" warnings.

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.

VincentLanglet and others added 3 commits April 11, 2026 17:31
- 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>
@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-6ci6gsl branch from 21b91c0 to 6f358cd Compare April 11, 2026 15:31
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ?

…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>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's a summary of the changes to address the reviewer's question:

The reviewer asked why the getFiniteTypes() check can't be removed to allow isSuperTypeOf matching for all types (e.g., int|string matching int, or int<3, max> matching int<4, max>).

I verified the answer empirically by removing the check and running the test suite — 2 concrete regressions confirmed the check is necessary:

  1. pr-5379: non-falsy-string expected but 'filter' produced. A condition type of non-falsy-string (from BooleanAnd false-context processing in TypeSpecifier) incorrectly matched the narrowed type 'filter' via isSuperTypeOf, causing wrong narrowing.

  2. bug-14249: false expected but *NEVER* produced. Non-finite condition types from assignment handlers (like mixed~false) incorrectly matched via isSuperTypeOf, causing conflicting conditional expressions to both activate and produce true & false = *NEVER*.

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 isSuperTypeOf. The getFiniteTypes() guard restricts Pass 2 to closed sets of concrete values where subtype matching is safe.

I added a detailed comment in the code explaining all of this.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ?

…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>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's a summary of the changes made to address the reviewer's feedback:

Reviewer's example: A scope-merging case where $a = 0 followed by if (is_string($foo) || is_int($foo)) { $a = 1; } should narrow $a to 1 inside if (is_int($foo)). This falls under source 2 (scope merging) — the condition type is int|string, which is a UnionType but not finite.

What was changed:

Replaced the getFiniteTypes() guard with instanceof UnionType in Pass 2. The getFiniteTypes() check was too restrictive — it only allowed Pass 2 for closed sets of concrete values (constant strings, booleans, enum cases), which excluded non-finite union types like int|string from scope merging.

The UnionType guard is sufficient to prevent the known regressions because the problematic condition types are non-union:

  • non-falsy-string (from TypeSpecifier boolean processing) — an intersection type, not union
  • mixed~false (from assignment handlers) — a subtracted type, not union

Union types explicitly enumerate alternatives, making isSuperTypeOf subtype matching safe for them.

Test added: testScopeMerging in bug-10055.php verifying assertType('1', $a) inside if (is_int($foo)).

Verification: make tests (11840 tests, 79348 assertions) and make phpstan both pass with no regressions.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

phpstan-bot and others added 3 commits April 11, 2026 23:37
…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>
This prevents assignment handlers from creating conditional expressions
based on the impure function call, which could conflict with assertion
narrowing when Pass 2 uses isSuperTypeOf matching.

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>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

All three commits have been pushed. Here's a summary of the changes:

Commit 1: Skip trivially-always-true conditions in processBooleanSureConditionalTypes (TypeSpecifier.php)

  • When TypeCombinator::remove(scopeType, sureType) returns a type equal to the scope type, the condition is trivially always true and is now skipped
  • This fixes the pr-5379 regression at its root cause: conditions like $aggregation=array<string, mixed> (from TypeCombinator::remove(array<string, mixed>, narrowed_array) having no effect) and $type=non-falsy-string (from TypeCombinator::remove(non-falsy-string, 'filter') having no effect) are no longer created

Commit 2: Mark getMixed() test helper as @phpstan-impure (bug-14249.php)

  • Prevents assignment handlers from creating conditional expressions that conflict with assertion narrowing

Commit 3: Remove instanceof UnionType guard from Pass 2 (MutatingScope.php)

  • With the root causes fixed, Pass 2 can safely use isSuperTypeOf for all condition types without the UnionType restriction
  • This allows matching for any condition type (not just unions), enabling the scope merging case and other patterns

All 11840 tests pass (79348 assertions) and make phpstan reports no errors.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a non regression test for the change made in src/Analyser/TypeSpecifier.php‎ which failed before even without the change in the MutatingScope.

Tests that processBooleanSureConditionalTypes correctly skips conditions
where TypeCombinator::remove() returns the same type as the input. Without
the TypeSpecifier fix, BooleanAnd creates spurious conditional expressions
(e.g. $type=non-falsy-string) because remove(non-falsy-string, 'filter')
has no effect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. Added a non-regression test testTriviallyTrueConditionSkipped in tests/PHPStan/Analyser/nsrt/bug-10055.php that specifically exercises the TypeSpecifier fix independently of the MutatingScope Pass 2 change. The test reproduces the scenario where processBooleanSureConditionalTypes was creating spurious conditional expressions because TypeCombinator::remove(non-falsy-string, 'filter') returns non-falsy-string unchanged — without the $scopeType->equals($conditionType) skip, this led to incorrect type narrowing.

All tests pass (11840 tests, 79348 assertions) and make phpstan reports no errors.

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.

2 participants