Skip to content

Erase generic type arguments when comparing a class-string against a constant class-string - #6485

Open
phpstan-bot wants to merge 2 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-9n3wj48
Open

phpstan-bot wants to merge 2 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-9n3wj48

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

A union of parameterized class-string types was not narrowed after a class name was excluded with !==: class-string<X<int>>|class-string<Y<int>> stayed unchanged instead of narrowing to class-string<Y<int>>, while the equivalent union of raw class-string<X>|class-string<Y> narrowed correctly. Star-projected class-string<X<*>> was affected the same way.

The fix erases generic type arguments before a class-string is compared against a constant class-string, because a class-string value only ever carries a class name.

Changes

  • src/Type/Generic/GenericClassStringType.php
    • New @internal static isValueOfGenericType(Type $genericType, string $className): IsSuperTypeOfResult holding the "can a class named $className be the value behind class-string<$genericType>" check that was previously duplicated in two places.
    • New private eraseTypeArguments() which maps every GenericObjectType inside the generic type to its unparameterized ObjectType counterpart (keeping the subtracted type) via TypeTraverser.
    • isSuperTypeOf() now delegates its ConstantStringType branch to the new helper.
  • src/Type/Constant/ConstantStringType.php
    • The GenericClassStringType branch of isSuperTypeOf() delegates to the same helper instead of repeating the StaticType/TemplateType/ObjectType dance.

Analogous cases that were broken by the same root cause and are fixed by this change (each covered by a new assertion):

  • star projections — class-string<X<*>>|class-string<Y<*>>
  • a union nested inside one class-stringclass-string<X<int>|Y<int>>
  • === narrowing, which used to produce a stray 'X'&class-string<X<int>> intersection instead of 'X'
  • switch / default arm narrowing
  • in_array($class, [X::class], true) narrowing
  • match exhaustiveness: "Match expression does not handle remaining values" false positive
  • constant-array offset access keyed by class names: $map[$class] reported "Offset ... does not exist" and produced *ERROR*, now 1|2 and "might not exist" (same as for raw class-string<X>)
  • TypeCombinator::union() now absorbs 'X' into class-string<X<int>>
  • class-string<X<int>>&literal-string now subtracts to *NEVER* like its raw counterpart

Probed 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 from X::class.

Root cause

ConstantStringType::isSuperTypeOf() and GenericClassStringType::isSuperTypeOf() both answered "is C::class a possible value of class-string<G>?" by turning the constant into new ObjectType(C) and requiring G->isSuperTypeOf(ObjectType(C)) to be a definite Yes. The Yes requirement is deliberate — it filters out the uncertainty that ObjectType(C) could really be a subclass of C.

But when G is parameterized, GenericObjectType::isSuperTypeOf() can never return Yes for an unparameterized ObjectType(C): it looks up C's ancestor with the same class name, finds a plain ObjectType, and downgrades the result to Maybe because the type arguments are unknown. So the check collapsed to No, TypeCombinator::remove() bailed out at its isSuperType->no() early return and never reached GenericClassStringType::tryRemove(), and no narrowing happened.

A class-string value 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 makes class-string<X<int>> and class-string<X<*>> behave exactly like class-string<X> in this comparison. Erasure — rather than switching to an accepts context — is what keeps the existing precision for subtracted object types such as class-string<Type~(ConstantIntegerType|IntegerRangeType)>, where the Maybe genuinely 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.php and MatchExpressionRuleTest::testBug15266() — a match over a parameterized/star-projected class-string union is expected to report nothing; without the fix it reports "Match expression does not handle remaining values" twice.

Fixes phpstan/phpstan#15266

…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`.
Comment thread src/Type/Generic/GenericClassStringType.php Outdated
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@VincentLanglet VincentLanglet self-assigned this Sep 20, 2026
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.

Parameterized class-string is not narrowed after excluding a final class name

2 participants