Erase generic type arguments when comparing a class-string against a constant class-string - #6485
Open
phpstan-bot wants to merge 2 commits into
Open
phpstan-bot wants to merge 2 commits into
phpstan-bot wants to merge 2 commits into
Conversation
…a constant class-string - A `class-string` value carries only a class name, never type arguments, so `GenericObjectType::isSuperTypeOf()` returning `Maybe` for an unparameterized class name must not make `'X'` a non-member of `class-string<X<int>>`. - Added `GenericClassStringType::isValueOfGenericType()`, which strips generic type arguments (via `TypeTraverser`) from the generic type before comparing it against `ObjectType($className)`, and used it from both places that did the constant class-string comparison by hand: `GenericClassStringType::isSuperTypeOf()` and `ConstantStringType::isSuperTypeOf()`. - Subtracted object types keep their precision because erasure only replaces `GenericObjectType` with its unparameterized `ObjectType` counterpart. - Fixes the same family of misses that all funnelled through those two methods: `class-string<X<*>>` star projections, unions nested inside a single `class-string<X<int>|Y<int>>`, `===` narrowing (which produced a stray `'X'&class-string<X<int>>` intersection), `switch`/`default` narrowing, `in_array()` narrowing, `match` exhaustiveness false positives, constant-array offset access keyed by `X::class` (reported "Offset does not exist" and `*ERROR*`), and `TypeCombinator::union()` failing to absorb `'X'` into `class-string<X<int>>`. - Probed and found already correct: `GenericClassStringType::accepts()` (an accepts context already ignores the type arguments of an unparameterized class name), `is_a()`/`is_subclass_of()` narrowing, `instanceof $class`, and template type inference from `X::class`.
VincentLanglet
requested changes
Sep 20, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
VincentLanglet
approved these changes
Sep 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A union of parameterized
class-stringtypes was not narrowed after a class name was excluded with!==:class-string<X<int>>|class-string<Y<int>>stayed unchanged instead of narrowing toclass-string<Y<int>>, while the equivalent union of rawclass-string<X>|class-string<Y>narrowed correctly. Star-projectedclass-string<X<*>>was affected the same way.The fix erases generic type arguments before a
class-stringis compared against a constant class-string, because aclass-stringvalue only ever carries a class name.Changes
src/Type/Generic/GenericClassStringType.php@internalstaticisValueOfGenericType(Type $genericType, string $className): IsSuperTypeOfResultholding the "can a class named$classNamebe the value behindclass-string<$genericType>" check that was previously duplicated in two places.eraseTypeArguments()which maps everyGenericObjectTypeinside the generic type to its unparameterizedObjectTypecounterpart (keeping the subtracted type) viaTypeTraverser.isSuperTypeOf()now delegates itsConstantStringTypebranch to the new helper.src/Type/Constant/ConstantStringType.phpGenericClassStringTypebranch ofisSuperTypeOf()delegates to the same helper instead of repeating theStaticType/TemplateType/ObjectTypedance.Analogous cases that were broken by the same root cause and are fixed by this change (each covered by a new assertion):
class-string<X<*>>|class-string<Y<*>>class-string—class-string<X<int>|Y<int>>===narrowing, which used to produce a stray'X'&class-string<X<int>>intersection instead of'X'switch/defaultarm narrowingin_array($class, [X::class], true)narrowingmatchexhaustiveness: "Match expression does not handle remaining values" false positive$map[$class]reported "Offset ... does not exist" and produced*ERROR*, now1|2and "might not exist" (same as for rawclass-string<X>)TypeCombinator::union()now absorbs'X'intoclass-string<X<int>>class-string<X<int>>&literal-stringnow subtracts to*NEVER*like its raw counterpartProbed and found already correct, so no change and no test kept:
GenericClassStringType::accepts()(an accepts context already treats an unparameterized class name as compatible with any parameterization),is_a()/is_subclass_of()narrowing,instanceof $class, and template type inference fromX::class.Root cause
ConstantStringType::isSuperTypeOf()andGenericClassStringType::isSuperTypeOf()both answered "isC::classa possible value ofclass-string<G>?" by turning the constant intonew ObjectType(C)and requiringG->isSuperTypeOf(ObjectType(C))to be a definiteYes. TheYesrequirement is deliberate — it filters out the uncertainty thatObjectType(C)could really be a subclass ofC.But when
Gis parameterized,GenericObjectType::isSuperTypeOf()can never returnYesfor an unparameterizedObjectType(C): it looks upC's ancestor with the same class name, finds a plainObjectType, and downgrades the result toMaybebecause the type arguments are unknown. So the check collapsed toNo,TypeCombinator::remove()bailed out at itsisSuperType->no()early return and never reachedGenericClassStringType::tryRemove(), and no narrowing happened.A
class-stringvalue is just a class name and cannot carry type arguments, so that particular uncertainty must not count against the match. The fix erases the type arguments from the generic type before the comparison, which makesclass-string<X<int>>andclass-string<X<*>>behave exactly likeclass-string<X>in this comparison. Erasure — rather than switching to an accepts context — is what keeps the existing precision for subtracted object types such asclass-string<Type~(ConstantIntegerType|IntegerRangeType)>, where theMaybegenuinely comes from the subtraction and not from type arguments.Test
tests/PHPStan/Analyser/nsrt/bug-15266.php— the playground reproducer verbatim (parameterized, star-projected and raw unions), plus one function per analogous case listed above. All 11 new assertions fail without the source change.tests/PHPStan/Rules/Comparison/data/bug-15266.phpandMatchExpressionRuleTest::testBug15266()— amatchover a parameterized/star-projectedclass-stringunion is expected to report nothing; without the fix it reports "Match expression does not handle remaining values" twice.Fixes phpstan/phpstan#15266