Skip to content

Keep type arguments when narrowing a generic type to a subclass - #6562

Open
miguelmeloninnatial wants to merge 3 commits into
phpstan:2.2.xfrom
miguelmeloninnatial:bug-15289
Open

miguelmeloninnatial wants to merge 3 commits into
phpstan:2.2.xfrom
miguelmeloninnatial:bug-15289

Conversation

@miguelmeloninnatial

@miguelmeloninnatial miguelmeloninnatial commented Sep 23, 2026

Copy link
Copy Markdown

When a generic type is narrowed to a subclass, the subclass now keeps the type arguments that the generic type implies for it:

A subclass stays bare when the generic type does not determine all of its arguments, or when the generic type has call-site variance.

The first commit fixes an older, separate bug that the second commit reaches: a class name compared with a class-string of a parameterized class (class-string<Box<string>> against Box::class, with Box final) did not narrow, and a match over it reported the class as unhandled. Without it, testBug14412 fails after the second commit. The third commit extends it to a class-string with call-site variance, such as class-string<X<*>>.

The issue bot shows the effect on three more open issues:

The benchmark job fails against the committed baseline on a different file in each run: bug-14972.php and bug-14996.php on PHP 7.4 in the first run, bug-13352.php on PHP 8.5 in the second. bug-13352.php also fails on 2.2.x itself. On one machine, alternating runs of the base and this branch (two rounds of five iterations) show no difference beyond run-to-run noise on these three files, and peak memory is no higher.

On 2.3.x, the four changed source classes are shadowed by turbo-ext, so the merge into 2.3.x needs the same change in their C++ mirrors.

Closes phpstan/phpstan#15289
Closes phpstan/phpstan#15266
Closes phpstan/phpstan#9189

A class name carries no type arguments.
`ConstantStringType::isSuperTypeOf()` and
`GenericClassStringType::isSuperTypeOf()` compared it as a bare `ObjectType`
with the generic type of a class-string, and `Box<string>` is only maybe a
supertype of a bare `Box`. So with a final `Box`,
`$c === Box::class` did not narrow a `class-string<Box<string>>` to never in
the else branch, and a `match` over it reported the class as unhandled.

The class name now gets the type arguments that the generic type implies for
it, through the new `GenericObjectType::specializeSubclass()`. A class that
is not generic, such as one that implements `Option<string>`, is still not
a value of `class-string<Option<int>>`.
A generic class written without type arguments is a subtype of every
parameterization of its ancestors, so `TypeCombinator::intersect()` kept the
bare subclass and dropped the arguments: `instanceof Some` narrowed an
`Option<int>` to `Some`, and so did `is_a()`, `@phpstan-assert` and
`$o::class` comparisons. Since `GenericObjectType::changeSubtractedType()`
returns the lone remaining subtype of a sealed hierarchy (phpstan#5369), that
subtype lost its arguments too: `Result<int, string>` without `Ok` became
`Err`. The merge after the branch no longer hid the loss, so a wrong return
type was accepted.

Both now take the arguments that the generic type implies for the subclass.
A subclass stays bare when the generic type does not determine all of its
arguments or has call-site variance. In a union, a bare subtracted subclass
is read within its type: `Option<int>~Some` together with `Some<int>` gives
`Option<int>`.
A class name compared with a class-string of its own generic class now
takes the type arguments and the call-site variance of that class-string as
they are written: `X::class` read against `class-string<X<*>>` is `X<*>`.
So `$class !== X::class` narrows `class-string<X<*>>|class-string<Y<*>>` to
`class-string<Y<*>>`. Before, a class-string with call-site variance left
the class name bare, which is only maybe an `X<*>`, and nothing narrowed.

A subclass still stays bare when the class-string has call-site variance.
@miguelmeloninnatial
miguelmeloninnatial marked this pull request as ready for review September 23, 2026 15:22
@phpstan-bot

Copy link
Copy Markdown
Collaborator

This pull request has been marked as ready for review.

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