🌐 US-Proxy
class="logged-out env-production page-responsive" style="word-wrap: break-word;" >
Skip to content

Treat plain class-string as a definite subtype of class-string<object> in GenericClassStringType::isSuperTypeOf() - #5956

Merged
ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-9d0rcj2
Jun 30, 2026
Merged

Treat plain class-string as a definite subtype of class-string<object> in GenericClassStringType::isSuperTypeOf()#5956
ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-9d0rcj2

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

new ReflectionClass($class) reported a false argument.type error when $class was a
class-string narrowed through is_subclass_of($class, $interface) (with $interface a
class-string<T>). The narrowed argument has type (class-string&T)|class-string<T>, and
PHPStan claimed it was not accepted by the constructor's class-string<object>|object
parameter. The fix makes class-string<object> correctly recognize a plain class-string
as a definite subtype.

Changes

  • src/Type/Generic/GenericClassStringType.php: added a ClassStringType branch to
    isSuperTypeOf() that treats a plain class-string as class-string<object> and
    delegates to $this->type->isSuperTypeOf(new ObjectWithoutClassType()). This mirrors the
    branch already present in accepts() and the isClassString() handling in
    inferTemplateTypes().
  • tests/PHPStan/Type/Generic/GenericClassStringTypeTest.php: added dataIsSuperTypeOf
    cases for class-string<object> and class-string<T of object> against a plain
    class-string.
  • tests/PHPStan/Rules/Classes/InstantiationRuleTest.php + data/bug-14887.php: rule
    regression test reproducing the issue's playground snippet.
  • phpstan-baseline.neon: bumped the existing baselined instanceof ClassStringType
    count for this file from 1 to 2 (the new branch reuses the same established pattern as
    the adjacent accepts() branch).

Root cause

GenericClassStringType::accepts() and inferTemplateTypes() already handle a plain
ClassStringType by treating it as class-string<object> (via ObjectWithoutClassType),
but isSuperTypeOf() was missing the parallel branch — a plain class-string fell into the
generic StringType branch and returned Maybe. That stray Maybe is wrong: every
class-string is a class-string<object>.

The bug surfaced because of the intersection-acceptance heuristic added in bad7874
(#13190): when an intersection like class-string&T contains a TemplateType and the
holistic isSuperTypeOf() only returns Maybe, the heuristic distrusts the eager
template-lenient accepts() Yes and reports Maybe. Correcting the imprecise Maybe to
Yes makes the holistic check return Yes, so the heuristic no longer fires and the
intersection is again a definite subtype. The pattern is "a non-generic refined type must be
recognized as a subtype of its generic form across accepts(), isSuperTypeOf(), and
inferTemplateTypes()"; the only out-of-sync location was isSuperTypeOf().

Test

  • Unit: GenericClassStringTypeTest::testIsSuperTypeOf data sets 19/20 assert
    class-string<object> is a Yes supertype of plain class-string, and
    class-string<T of object> is Maybe (correct for an invariant template).
  • Rule: InstantiationRuleTest::testBug14887 analyses the exact playground snippet and
    expects no errors. Both tests fail before the fix and pass after.
  • Probed is_a() as an analogous narrowing construct: it was broken in the same way and is
    fixed by the same change.

Fixes phpstan/phpstan#14887

…ect>` in `GenericClassStringType::isSuperTypeOf()`

- Add a `ClassStringType` branch to `GenericClassStringType::isSuperTypeOf()` that
  treats a plain `class-string` as `class-string<object>` and compares the generic
  type against `ObjectWithoutClassType`, mirroring the branch that already exists in
  `accepts()` and `inferTemplateTypes()`. Previously a plain `class-string` fell through
  to the generic `StringType` branch and returned `Maybe`.
- This is the root cause exposed by #14887: after `is_subclass_of()`/`is_a()` narrowing,
  the argument has type `(class-string&T)|class-string<T>`; the intersection acceptance
  heuristic added in bad7874 distrusts the eager `accepts()` Yes when the holistic
  `isSuperTypeOf()` only returns `Maybe`. With the imprecise `Maybe` corrected to `Yes`,
  the intersection is again a definite subtype and the false `new ReflectionClass($class)`
  error disappears, while the #13190 cases stay correctly reported.
- Probed analogous narrowing constructs: both `is_subclass_of()` and `is_a()` produced the
  false positive and are now fixed by the same change.
- Added unit test cases to GenericClassStringTypeTest::dataIsSuperTypeOf and a rule
  regression test (bug-14887) reproducing the playground snippet.
@ondrejmirtes
ondrejmirtes merged commit aa3edb6 into phpstan:2.2.x Jun 30, 2026
374 of 375 checks passed
@ondrejmirtes
ondrejmirtes deleted the create-pull-request/patch-9d0rcj2 branch June 30, 2026 20:21
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.

new ReflectionClass($class) does not accept what it should accept

2 participants