Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 60 additions & 2 deletions src/Analyser/TypeSpecifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
use PHPStan\Type\FloatType;
use PHPStan\Type\FunctionTypeSpecifyingExtension;
use PHPStan\Type\Generic\GenericClassStringType;
use PHPStan\Type\Generic\GenericObjectType;
use PHPStan\Type\Generic\TemplateType;
use PHPStan\Type\Generic\TemplateTypeHelper;
use PHPStan\Type\Generic\TemplateTypeVariance;
Expand Down Expand Up @@ -86,6 +87,7 @@
use function array_merge;
use function array_reverse;
use function array_shift;
use function array_values;
use function count;
use function in_array;
use function is_string;
Expand Down Expand Up @@ -2577,6 +2579,48 @@ public function resolveIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, Ty
return $specifiedTypes;
}

private function narrowTypePreservingGenerics(ObjectType $narrowedType, Type $currentVarType): Type
{
// Intersect with current type — for unions (e.g., Cat<string>|Dog<string>),
// TypeCombinator distributes over members and preserves generic parameters
$intersected = TypeCombinator::intersect($narrowedType, $currentVarType);
if (!$intersected instanceof NeverType && !$intersected->equals($narrowedType)) {
return $intersected;
}

// For generic parent types (e.g., Animal<string>), resolve child's
// template parameters through the class hierarchy
$currentClassReflections = $currentVarType->getObjectClassReflections();
if (
count($currentClassReflections) === 1
&& count($currentClassReflections[0]->getTemplateTypeMap()->getTypes()) > 0
) {
$className = $narrowedType->getClassName();
if ($this->reflectionProvider->hasClass($className)) {
$childReflection = $this->reflectionProvider->getClass($className);
$childTemplateTypes = $childReflection->getTemplateTypeMap()->getTypes();
if (count($childTemplateTypes) > 0) {
$freshChildType = new GenericObjectType($className, array_values($childTemplateTypes));
$currentClassNames = $currentVarType->getObjectClassNames();
$ancestorAsParent = count($currentClassNames) === 1
? $freshChildType->getAncestorWithClassName($currentClassNames[0])
: null;
if ($ancestorAsParent !== null) {
$inferredMap = $ancestorAsParent->inferTemplateTypes($currentVarType);
$resolvedTypes = [];
foreach ($childTemplateTypes as $name => $templateType) {
$resolved = $inferredMap->getType($name);
$resolvedTypes[] = $resolved ?? $templateType;
}
return new GenericObjectType($className, $resolvedTypes);
}
}
}
}

return $narrowedType;
}

private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes
{
$leftExpr = $expr->left;
Expand Down Expand Up @@ -2879,9 +2923,16 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
strtolower($unwrappedLeftExpr->name->toString()) === 'class'
) {
if ($this->reflectionProvider->hasClass($rightType->getValue())) {
$className = $rightType->getValue();
$classReflection = $this->reflectionProvider->getClass($className);
$narrowedType = new ObjectType($className, classReflection: $classReflection->asFinal());

$currentVarType = $scope->getType($unwrappedLeftExpr->class);
$narrowedType = $this->narrowTypePreservingGenerics($narrowedType, $currentVarType);

return $this->create(
$unwrappedLeftExpr->class,
new ObjectType($rightType->getValue(), classReflection: $this->reflectionProvider->getClass($rightType->getValue())->asFinal()),
$narrowedType,
$context,
$scope,
)->unionWith($this->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr);
Expand Down Expand Up @@ -2910,9 +2961,16 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
strtolower($unwrappedRightExpr->name->toString()) === 'class'
) {
if ($this->reflectionProvider->hasClass($leftType->getValue())) {
$className = $leftType->getValue();
$classReflection = $this->reflectionProvider->getClass($className);
$narrowedType = new ObjectType($className, classReflection: $classReflection->asFinal());

$currentVarType = $scope->getType($unwrappedRightExpr->class);
$narrowedType = $this->narrowTypePreservingGenerics($narrowedType, $currentVarType);

return $this->create(
$unwrappedRightExpr->class,
new ObjectType($leftType->getValue(), classReflection: $this->reflectionProvider->getClass($leftType->getValue())->asFinal()),
$narrowedType,
$context,
$scope,
)->unionWith($this->create($rightExpr, $leftType, $context, $scope)->setRootExpr($expr));
Expand Down
70 changes: 70 additions & 0 deletions src/Type/Generic/GenericClassStringType.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@
use PHPStan\Type\ObjectWithoutClassType;
use PHPStan\Type\StaticType;
use PHPStan\Type\StringType;
use PHPStan\Type\SubtractableType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\TypeUtils;
use PHPStan\Type\UnionType;
use PHPStan\Type\VerbosityLevel;
use function array_keys;
use function count;
use function sprintf;

Expand Down Expand Up @@ -219,6 +222,73 @@ public function tryRemove(Type $typeToRemove): ?Type
if ($classReflection->isFinal() && $genericObjectClassNames[0] === $typeToRemove->getValue()) {
return new NeverType();
}

$allowedSubTypes = $classReflection->getAllowedSubTypes();
if ($allowedSubTypes !== null) {
$classToRemove = $typeToRemove->getValue();

// Build O(1) lookup of previously subtracted class names
$subtractedClassNames = [];
if ($generic instanceof SubtractableType) {
$existingSubtracted = $generic->getSubtractedType();
if ($existingSubtracted !== null) {
foreach (TypeUtils::flattenTypes($existingSubtracted) as $type) {
foreach ($type->getObjectClassNames() as $name) {
$subtractedClassNames[$name] = true;
}
}
}
}

// Single pass: verify classToRemove is allowed and collect remaining
$isAllowedSubType = false;
$remainingAllowedSubTypes = [];
foreach ($allowedSubTypes as $allowedSubType) {
$names = $allowedSubType->getObjectClassNames();
if (count($names) === 1) {
if ($names[0] === $classToRemove) {
$isAllowedSubType = true;
continue;
}
if (isset($subtractedClassNames[$names[0]])) {
continue;
}
}
$remainingAllowedSubTypes[] = $allowedSubType;
}

if ($isAllowedSubType) {
if (count($remainingAllowedSubTypes) === 0) {
return new NeverType();
}

// When removing the sealed class itself (concrete non-abstract parent),
// narrow to remaining subtypes directly. ObjectType subtraction would
// create a self-referential type (e.g., Foo~Foo) that incorrectly
// excludes child class-strings due to covariant subtraction semantics.
if ($genericObjectClassNames[0] === $classToRemove) {
if (count($remainingAllowedSubTypes) === 1) {
return new self($remainingAllowedSubTypes[0]);
}

return new self(TypeCombinator::union(...$remainingAllowedSubTypes));
}

// Build subtracted type: previous + new removal
$subtractedClassNames[$classToRemove] = true;
$subtractedTypes = [];
foreach (array_keys($subtractedClassNames) as $name) {
$subtractedTypes[] = new ObjectType($name);
}
$newSubtracted = count($subtractedTypes) === 1
? $subtractedTypes[0]
: new UnionType($subtractedTypes);

return new self(
new ObjectType($genericObjectClassNames[0], $newSubtracted),
);
}
}
}
} elseif (count($genericObjectClassNames) > 1) {
$objectTypeToRemove = new ObjectType($typeToRemove->getValue());
Expand Down
116 changes: 116 additions & 0 deletions tests/PHPStan/Analyser/nsrt/class-string-match-preserves-generics.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php // lint >= 8.0

declare(strict_types = 1);

namespace ClassStringMatchPreservesGenerics;

use function PHPStan\Testing\assertType;

// === Setup: generic parent with subtypes ===

/** @template T */
abstract class Animal {
/** @return T */
abstract public function value(): mixed;
}

/**
* @template T
* @extends Animal<T>
*/
class Cat extends Animal {
/** @param T $val */
public function __construct(private mixed $val) {}
/** @return T */
public function value(): mixed { return $this->val; }
}

/**
* @template T
* @extends Animal<T>
*/
class Dog extends Animal {
/** @return never */
public function value(): never { throw new \RuntimeException(); }
}

// === 1. Union type: ::class match preserves generic parameter ===

/** @param Cat<string>|Dog<string> $a */
function unionMatchPreservesGeneric(Animal $a): void {
match ($a::class) {
Cat::class => assertType('string', $a->value()),
Dog::class => assertType('never', $a->value()),
};
}

// === 2. If-else with ::class preserves generic parameter ===

/** @param Cat<int>|Dog<int> $a */
function ifElseClassPreservesGeneric(Animal $a): void {
if ($a::class === Cat::class) {
assertType('int', $a->value());
} else {
// The falsey branch uses the general === handler (not the ::class special path),
// so the object variable is not narrowed with generics — only the class-string is.
assertType('int', $a->value());
}
}

// === 3. Single generic type (not union): ::class narrows and preserves generic ===

/** @param Animal<string> $a */
function singleGenericClassMatch(Animal $a): void {
if ($a::class === Cat::class) {
assertType('string', $a->value());
}
}

// === 4. Final generic class: ::class preserves generic ===

/** @template T */
final class Box {
/** @param T $val */
public function __construct(private mixed $val) {}
/** @return T */
public function get(): mixed { return $this->val; }
}

/** @param Box<string> $box */
function finalGenericClassMatch(Box $box): void {
if ($box::class === Box::class) {
assertType('string', $box->get());
}
}

// === 5. Mirror case: 'Foo' === $a::class also preserves generics ===

/** @param Cat<float>|Dog<float> $a */
function mirrorCasePreservesGeneric(Animal $a): void {
if (Cat::class === $a::class) {
assertType('float', $a->value());
}
}

// === 6. Match with multiple arms and method calls ===

/** @param Cat<array<string>>|Dog<array<string>> $a */
function matchWithMethodCall(Animal $a): void {
$result = match ($a::class) {
Cat::class => $a->value(),
Dog::class => [],
};
assertType('array<string>', $result);
}

// === 7. Non-matching class: no narrowing side-effects ===

/** @param Cat<string>|Dog<string> $a */
function nonMatchingClass(Animal $a): void {
if ($a::class === \stdClass::class) {
// Unreachable: stdClass is not in Cat|Dog union, so the intersection is never
assertType('*NEVER*', $a);
} else {
assertType('ClassStringMatchPreservesGenerics\Cat<string>|ClassStringMatchPreservesGenerics\Dog<string>', $a);
}
}
Loading
Loading