From 120821aac419212d000dff92d9c1a150bbefde85 Mon Sep 17 00:00:00 2001 From: Miguel Melon <73828966+miguelmeloninnatial@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:07:24 +0200 Subject: [PATCH 1/3] Read a class name in the parameterization a class-string implies 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` is only maybe a supertype of a bare `Box`. So with a final `Box`, `$c === Box::class` did not narrow a `class-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`, is still not a value of `class-string>`. --- phpstan-baseline.neon | 2 +- src/Type/Constant/ConstantStringType.php | 8 +++ src/Type/Generic/GenericClassStringType.php | 7 +++ src/Type/Generic/GenericObjectType.php | 51 +++++++++++++++++++ .../nsrt/class-string-of-generic-class.php | 51 +++++++++++++++++++ .../Comparison/MatchExpressionRuleTest.php | 6 +++ 6 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/class-string-of-generic-class.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index d8ee14cf595..31c6bd31bfb 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1119,7 +1119,7 @@ parameters: - rawMessage: Doing instanceof PHPStan\Type\Generic\GenericObjectType is error-prone and deprecated. identifier: phpstanApi.instanceofType - count: 4 + count: 5 path: src/Type/Generic/GenericObjectType.php - diff --git a/src/Type/Constant/ConstantStringType.php b/src/Type/Constant/ConstantStringType.php index 5994a945569..b4bb8833dc6 100644 --- a/src/Type/Constant/ConstantStringType.php +++ b/src/Type/Constant/ConstantStringType.php @@ -32,6 +32,7 @@ use PHPStan\Type\ErrorType; use PHPStan\Type\GeneralizePrecision; use PHPStan\Type\Generic\GenericClassStringType; +use PHPStan\Type\Generic\GenericObjectType; use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\InstanceofDeprecated; use PHPStan\Type\IntegerRangeType; @@ -172,6 +173,13 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult // an uncertainty originating in possible ObjectType's class subtypes. $objectType = $this->getObjectType(); + // A class name carries no type arguments, so it is compared in the + // parameterization the generic type implies for its class. + $objectType = GenericObjectType::specializeSubclass( + $genericType instanceof TemplateType ? $genericType->getBound() : $genericType, + $objectType, + ); + // Do not use TemplateType's isSuperTypeOf handling directly because it takes ObjectType // uncertainty into account. if ($genericType instanceof TemplateType) { diff --git a/src/Type/Generic/GenericClassStringType.php b/src/Type/Generic/GenericClassStringType.php index 7fd22cc8ecb..32889b81bfe 100644 --- a/src/Type/Generic/GenericClassStringType.php +++ b/src/Type/Generic/GenericClassStringType.php @@ -129,6 +129,13 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult // an uncertainty originating in possible ObjectType's class subtypes. $objectType = new ObjectType($type->getValue()); + // A class name carries no type arguments, so it is compared in the + // parameterization the generic type implies for its class. + $objectType = GenericObjectType::specializeSubclass( + $genericType instanceof TemplateType ? $genericType->getBound() : $genericType, + $objectType, + ); + // Do not use TemplateType's isSuperTypeOf handling directly because it takes ObjectType // uncertainty into account. if ($genericType instanceof TemplateType) { diff --git a/src/Type/Generic/GenericObjectType.php b/src/Type/Generic/GenericObjectType.php index 1373c268ee5..37b03b887e4 100644 --- a/src/Type/Generic/GenericObjectType.php +++ b/src/Type/Generic/GenericObjectType.php @@ -25,8 +25,10 @@ use PHPStan\Type\TypeWithClassName; use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; +use function array_keys; use function array_map; use function count; +use function get_class; use function implode; use function sprintf; @@ -450,6 +452,55 @@ public function changeSubtractedType(?Type $subtractedType): Type return new self($this->getClassName(), $this->types, $subtractedType, null, $this->variances); } + /** + * Gives $subclass, a class written without type arguments, the ones + * $supertype implies for it through the class's `@extends` and + * `@implements` tags: Some with Option is Some, Err with + * Result is Err. + * + * Returns $subclass unchanged unless $supertype is a generic object type + * without call-site variance, $subclass is a generic subtype of its class, + * and $supertype determines every type argument of $subclass - an explicit + * argument would claim more than is known. + */ + public static function specializeSubclass(Type $supertype, Type $subclass): Type + { + if (!$supertype instanceof self || get_class($subclass) !== ObjectType::class) { + return $subclass; + } + + foreach ($supertype->variances as $variance) { + if (!$variance->invariant()) { + return $subclass; + } + } + + $classReflection = $subclass->getClassReflection(); + if ($classReflection === null || !$classReflection->isGeneric()) { + return $subclass; + } + + $templateTypeMap = $classReflection->getTemplateTypeMap(); + $ancestor = (new self($classReflection->getName(), $classReflection->typeMapToList($templateTypeMap))) + ->getAncestorWithClassName($supertype->getClassName()); + if ($ancestor === null) { + return $subclass; + } + + $inferredTypeMap = $ancestor->inferTemplateTypes($supertype); + foreach (array_keys($templateTypeMap->getTypes()) as $templateName) { + if (!$inferredTypeMap->hasType($templateName)) { + return $subclass; + } + } + + return new self( + $classReflection->getName(), + $classReflection->typeMapToList($inferredTypeMap), + $subclass->getSubtractedType(), + ); + } + public function toPhpDocNode(): TypeNode { /** @var IdentifierTypeNode $parent */ diff --git a/tests/PHPStan/Analyser/nsrt/class-string-of-generic-class.php b/tests/PHPStan/Analyser/nsrt/class-string-of-generic-class.php new file mode 100644 index 00000000000..b02dfd0f06a --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/class-string-of-generic-class.php @@ -0,0 +1,51 @@ += 8.0 + +namespace ClassStringOfGenericClass; + +use function PHPStan\Testing\assertType; + +/** @template-covariant T */ +final class Box {} + +/** @template-covariant T */ +interface Option {} + +/** + * @template-covariant T + * @implements Option + */ +final class Some implements Option {} + +/** @implements Option */ +final class StringOption implements Option {} + +/** @param class-string> $c */ +function finalClass(string $c): void +{ + if ($c === Box::class) { + assertType("'ClassStringOfGenericClass\\\\Box'", $c); + return; + } + + assertType('*NEVER*', $c); +} + +/** @param class-string> $c */ +function subclass(string $c): void +{ + if ($c === Some::class) { + assertType("'ClassStringOfGenericClass\\\\Some'", $c); + } + + if ($c === StringOption::class) { + assertType('*NEVER*', $c); + } +} + +/** @param class-string> $c */ +function exhaustiveMatch(string $c): int +{ + return match ($c) { + Box::class => 1, + }; +} diff --git a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php index 063e1d531f0..f2d08cc5a79 100644 --- a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php @@ -515,6 +515,12 @@ public function testBug14412(): void $this->analyse([__DIR__ . '/data/bug-14412.php'], []); } + #[RequiresPhp('>= 8.0.0')] + public function testClassStringOfGenericClass(): void + { + $this->analyse([__DIR__ . '/../../Analyser/nsrt/class-string-of-generic-class.php'], []); + } + #[RequiresPhp('>= 8.0.0')] public function testBug13029(): void { From c9a3b336e7f270cf14dd574b75cf265bb00a166f Mon Sep 17 00:00:00 2001 From: Miguel Melon <73828966+miguelmeloninnatial@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:11:52 +0200 Subject: [PATCH 2/3] Keep type arguments when narrowing a generic type to a subclass 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` 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 (#5369), that subtype lost its arguments too: `Result` 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~Some` together with `Some` gives `Option`. --- src/Type/Generic/GenericObjectType.php | 8 +- src/Type/TypeCombinator.php | 29 ++- tests/PHPStan/Analyser/nsrt/bug-15289.php | 241 ++++++++++++++++++ .../Comparison/MatchExpressionRuleTest.php | 6 + .../Rules/Functions/ReturnTypeRuleTest.php | 25 ++ 5 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15289.php diff --git a/src/Type/Generic/GenericObjectType.php b/src/Type/Generic/GenericObjectType.php index 37b03b887e4..f905a293d72 100644 --- a/src/Type/Generic/GenericObjectType.php +++ b/src/Type/Generic/GenericObjectType.php @@ -445,10 +445,16 @@ public function changeSubtractedType(?Type $subtractedType): Type // Parent handles sealed type exhaustiveness (returning NeverType when all // allowed subtypes are subtracted, or a single remaining subtype). - if (!$result instanceof ObjectType || $result->getClassName() !== $this->getClassName()) { + if (!$result instanceof ObjectType) { return $result; } + // The remaining subtype comes back as the sealed tag names it, without + // type arguments, and takes the ones this type implies for it. + if ($result->getClassName() !== $this->getClassName()) { + return self::specializeSubclass($this, $result); + } + return new self($this->getClassName(), $this->types, $subtractedType, null, $this->variances); } diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index cd08b5615f0..0b3af51e315 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -24,6 +24,7 @@ use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\Generic\GenericClassStringType; +use PHPStan\Type\Generic\GenericObjectType; use PHPStan\Type\Generic\TemplateArrayType; use PHPStan\Type\Generic\TemplateBenevolentUnionType; use PHPStan\Type\Generic\TemplateMixedType; @@ -902,6 +903,20 @@ private static function intersectWithSubtractedType( } elseif ($isBAlreadySubtracted->yes()) { $subtractedType = self::remove($a->getSubtractedType(), $b); + // The subtracted type counts only within $a, where a class written + // without type arguments has the ones $a implies: in Option, + // a subtracted Some is used up by Some. + $subtractedWithinA = GenericObjectType::specializeSubclass( + $a->getTypeWithoutSubtractedType(), + $a->getSubtractedType(), + ); + if ( + $subtractedWithinA !== $a->getSubtractedType() + && self::remove($subtractedWithinA, $b) instanceof NeverType + ) { + $subtractedType = new NeverType(); + } + if ( $subtractedType instanceof NeverType || !$subtractedType->isSuperTypeOf($b)->no() @@ -1870,6 +1885,7 @@ public static function doIntersect(Type ...$types): Type // transform IntegerType & ConstantIntegerType to ConstantIntegerType // transform Child & Parent to Child + // transform Child & Parent to Child // transform Object & ~null to Object // transform A & A to A // transform int[] & string to never @@ -1887,7 +1903,13 @@ public static function doIntersect(Type ...$types): Type $isSuperTypeSubtractableA = $typeWithoutSubtractedTypeA->isSuperTypeOf($types[$i]); } if ($isSuperTypeSubtractableA->yes()) { - $types[$i] = self::unionWithSubtractedType($types[$i], $types[$j]->getSubtractedType()); + // A generic class written without type arguments is a subtype of + // every parameterization of its generic ancestors, so it is kept - + // with the arguments the dropped ancestor implies for it. + $types[$i] = self::unionWithSubtractedType( + GenericObjectType::specializeSubclass($typeWithoutSubtractedTypeA, $types[$i]), + $types[$j]->getSubtractedType(), + ); array_splice($types, $j--, 1); $typesCount--; continue 1; @@ -1903,7 +1925,10 @@ public static function doIntersect(Type ...$types): Type $isSuperTypeSubtractableB = $typeWithoutSubtractedTypeB->isSuperTypeOf($types[$j]); } if ($isSuperTypeSubtractableB->yes()) { - $types[$j] = self::unionWithSubtractedType($types[$j], $types[$i]->getSubtractedType()); + $types[$j] = self::unionWithSubtractedType( + GenericObjectType::specializeSubclass($typeWithoutSubtractedTypeB, $types[$j]), + $types[$i]->getSubtractedType(), + ); array_splice($types, $i--, 1); $typesCount--; continue 2; diff --git a/tests/PHPStan/Analyser/nsrt/bug-15289.php b/tests/PHPStan/Analyser/nsrt/bug-15289.php new file mode 100644 index 00000000000..d5effe7d220 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15289.php @@ -0,0 +1,241 @@ += 8.0 + +namespace Bug15289; + +use LogicException; +use function PHPStan\Testing\assertType; + +/** + * @template-covariant T + * @phpstan-sealed Some|None + */ +interface Option {} + +/** + * @template-covariant T + * @implements Option + */ +final class Some implements Option {} + +/** @implements Option */ +final class None implements Option {} + +/** + * @template-covariant T + * @template-covariant E + * @phpstan-sealed Ok|Err + */ +interface Result {} + +/** + * @template-covariant T + * @implements Result + */ +final class Ok implements Result {} + +/** + * @template-covariant E + * @implements Result + */ +final class Err implements Result {} + +/** + * @param Option $o + * @return Option + */ +function afterEmptyBranch(Option $o): Option +{ + if ($o instanceof Some) { + } + assertType('Bug15289\None|Bug15289\Some', $o); + + return $o; // should be reported +} + +/** + * @param Option $o + * @return Option + */ +function inTheBranch(Option $o): Option +{ + if ($o instanceof Some) { + assertType('Bug15289\Some', $o); + + return $o; // should be reported + } + + return $o; // correctly not reported: $o is None here +} + +/** + * @param Result $r + * @return Err + */ +function elseBranch(Result $r): Err +{ + if ($r instanceof Ok) { + throw new LogicException(); + } + assertType('Bug15289\Err', $r); + + return $r; // should be reported +} + +/** @param Option $o */ +function exhaustiveMatch(Option $o): int +{ + return match (true) { + $o instanceof Some => 1, + $o instanceof None => 2, + }; +} + +/** @template-covariant T */ +interface UnsealedOption {} + +/** + * @template-covariant T + * @implements UnsealedOption + */ +final class UnsealedSome implements UnsealedOption {} + +/** + * @param UnsealedOption $o + * @return UnsealedOption + */ +function unsealedControl(UnsealedOption $o): UnsealedOption +{ + if ($o instanceof UnsealedSome) { + assertType('Bug15289\UnsealedSome', $o); + } + assertType('Bug15289\UnsealedOption', $o); + + return $o; // reported +} + +/** @param Result $r */ +function truthyBranchOfEachVariant(Result $r): void +{ + if ($r instanceof Err) { + assertType('Bug15289\Err', $r); + } else { + assertType('Bug15289\Ok', $r); + } +} + +/** + * @template T + * @param Option $o + */ +function templateArgument(Option $o): void +{ + assertType('Bug15289\Option', $o); + if ($o instanceof Some) { + assertType('Bug15289\Some', $o); + } + assertType('Bug15289\None|Bug15289\Some', $o); +} + +/** @template T */ +interface Invariant {} + +/** + * @template T + * @implements Invariant + */ +final class InvariantImpl implements Invariant {} + +/** @param Invariant $i */ +function callSiteVariance(Invariant $i): void +{ + if ($i instanceof InvariantImpl) { + assertType('Bug15289\InvariantImpl', $i); + } +} + +/** @phpstan-assert Some $o */ +function assertSome(Option $o): void +{ +} + +/** @param Option|null $o */ +function otherNarrowings(?Option $o): void +{ + if ($o instanceof Some) { + assertType('Bug15289\Some', $o); + } + + if ($o === null) { + return; + } + + if (is_a($o, Some::class)) { + assertType('Bug15289\Some', $o); + } + + if ($o::class === Some::class) { + assertType('Bug15289\Some', $o); + } + + assertSome($o); + assertType('Bug15289\Some', $o); +} + +/** + * @template-covariant T + * @phpstan-sealed Plain|Tagged + */ +interface Wrapped {} + +/** + * @template-covariant T + * @implements Wrapped + */ +final class Plain implements Wrapped {} + +/** + * @template-covariant T + * @template-covariant Tag + * @implements Wrapped + */ +final class Tagged implements Wrapped {} + +/** @param Wrapped $w */ +function undeterminedArgument(Wrapped $w): void +{ + if ($w instanceof Tagged) { + assertType('Bug15289\Tagged', $w); + } +} + +/** @param Wrapped $w */ +function undeterminedRemainder(Wrapped $w): void +{ + if ($w instanceof Plain) { + assertType('Bug15289\Plain', $w); + return; + } + + assertType('Bug15289\Tagged', $w); +} + +function bareSupertype(UnsealedOption $o): void +{ + assertType('Bug15289\UnsealedOption', $o); + if ($o instanceof UnsealedSome) { + assertType('Bug15289\UnsealedSome', $o); + } +} + +/** + * @template O of Option + * @param O $o + */ +function templateRemainder(Option $o): void +{ + if ($o instanceof None) { + return; + } + + assertType('O of Bug15289\Some (function Bug15289\templateRemainder(), argument)', $o); +} diff --git a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php index f2d08cc5a79..916c2c8f759 100644 --- a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php @@ -515,6 +515,12 @@ public function testBug14412(): void $this->analyse([__DIR__ . '/data/bug-14412.php'], []); } + #[RequiresPhp('>= 8.0.0')] + public function testBug15289(): void + { + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-15289.php'], []); + } + #[RequiresPhp('>= 8.0.0')] public function testClassStringOfGenericClass(): void { diff --git a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php index 7b3deb0bf63..db112d99eed 100644 --- a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php @@ -516,4 +516,29 @@ public function testBug15245(): void ]); } + #[RequiresPhp('>= 8.0.0')] + public function testBug15289(): void + { + $this->checkNullables = true; + $this->checkExplicitMixed = false; + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-15289.php'], [ + [ + 'Function Bug15289\afterEmptyBranch() should return Bug15289\Option but returns Bug15289\None|Bug15289\Some.', + 52, + ], + [ + 'Function Bug15289\inTheBranch() should return Bug15289\Option but returns Bug15289\Some.', + 64, + ], + [ + 'Function Bug15289\elseBranch() should return Bug15289\Err but returns Bug15289\Err.', + 81, + ], + [ + 'Function Bug15289\unsealedControl() should return Bug15289\UnsealedOption but returns Bug15289\UnsealedOption.', + 113, + ], + ]); + } + } From b0572ceedec69c5891c2d8ab49a4d2fea909fa4d Mon Sep 17 00:00:00 2001 From: Miguel Melon <73828966+miguelmeloninnatial@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:20:46 +0200 Subject: [PATCH 3/3] Narrow a class-string with call-site variance by its class name 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>` is `X<*>`. So `$class !== X::class` narrows `class-string>|class-string>` to `class-string>`. 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. --- src/Type/Generic/GenericObjectType.php | 21 ++++++++-- tests/PHPStan/Analyser/nsrt/bug-15266.php | 50 +++++++++++++++++++++++ 2 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15266.php diff --git a/src/Type/Generic/GenericObjectType.php b/src/Type/Generic/GenericObjectType.php index f905a293d72..7ee1e94b35f 100644 --- a/src/Type/Generic/GenericObjectType.php +++ b/src/Type/Generic/GenericObjectType.php @@ -462,12 +462,15 @@ public function changeSubtractedType(?Type $subtractedType): Type * Gives $subclass, a class written without type arguments, the ones * $supertype implies for it through the class's `@extends` and * `@implements` tags: Some with Option is Some, Err with - * Result is Err. + * Result is Err. The class of $supertype itself + * takes its arguments and call-site variance as written: X with X<*> is + * X<*>. * * Returns $subclass unchanged unless $supertype is a generic object type - * without call-site variance, $subclass is a generic subtype of its class, - * and $supertype determines every type argument of $subclass - an explicit - * argument would claim more than is known. + * and $subclass is its class or a generic subtype of it. A subtype also + * stays unchanged when $supertype has call-site variance or does not + * determine every type argument of the subtype - an explicit argument + * would claim more than is known. */ public static function specializeSubclass(Type $supertype, Type $subclass): Type { @@ -475,6 +478,16 @@ public static function specializeSubclass(Type $supertype, Type $subclass): Type return $subclass; } + if ($subclass->getClassName() === $supertype->getClassName()) { + return new self( + $supertype->getClassName(), + $supertype->types, + $subclass->getSubtractedType(), + null, + $supertype->variances, + ); + } + foreach ($supertype->variances as $variance) { if (!$variance->invariant()) { return $subclass; diff --git a/tests/PHPStan/Analyser/nsrt/bug-15266.php b/tests/PHPStan/Analyser/nsrt/bug-15266.php new file mode 100644 index 00000000000..b3180360f70 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15266.php @@ -0,0 +1,50 @@ +> | class-string> $class + * @return class-string> | class-string> + */ +function parametrized(string $class): string +{ + if ($class !== X::class) { + assertType('class-string>', $class); + } + + return $class; +} + +/** + * @param class-string> | class-string> $class + * @return class-string> | class-string> + */ +function star(string $class): string +{ + if ($class !== X::class) { + assertType('class-string>', $class); + } + + return $class; +} + +/** + * @param class-string | class-string $class + * @return class-string | class-string + */ +function raw(string $class): string +{ + if ($class !== X::class) { + assertType('class-string', $class); + } + + return $class; +}