Skip to content

Commit 47fa586

Browse files
committed
Fix phpstan/phpstan#14188: Preserve template type when narrowing class-string parameter
- Modified NewHandler::resolveType() to intersect the template type from the parameter's declared class-string<T> type with the concrete object type when evaluating `new $class()` after the class-string has been narrowed - Added rule regression test in tests/PHPStan/Rules/Methods/data/bug-14188.php - Added NSRT test in tests/PHPStan/Analyser/nsrt/bug-14188.php - Root cause: when class-string<T> is narrowed via === to a constant class string, `new $class()` lost the template type T and returned a plain ObjectType, causing the return type check to report a false positive "breaks the contract" error
1 parent cfba43c commit 47fa586

4 files changed

Lines changed: 91 additions & 1 deletion

File tree

src/Analyser/ExprHandler/NewHandler.php

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
use function array_map;
5454
use function array_merge;
5555
use function count;
56+
use function is_string;
5657
use function sprintf;
5758

5859
/**
@@ -308,7 +309,39 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type
308309
}
309310

310311
$exprType = $scope->getType($expr->class);
311-
return $exprType->getObjectTypeOrClassStringObjectType();
312+
$objectType = $exprType->getObjectTypeOrClassStringObjectType();
313+
314+
// When $class has been narrowed from class-string<T> to a concrete class string,
315+
// preserve the template type relationship by intersecting with the template type.
316+
// This ensures that `new $class()` is still compatible with return type T.
317+
if (
318+
$expr->class instanceof Expr\Variable
319+
&& is_string($expr->class->name)
320+
&& !$objectType->hasTemplateOrLateResolvableType()
321+
) {
322+
$function = $scope->getFunction();
323+
if ($function !== null) {
324+
foreach ($function->getParameters() as $param) {
325+
if ($param->getName() !== $expr->class->name) {
326+
continue;
327+
}
328+
$paramType = $param->getType();
329+
if (!$paramType->isClassString()->yes()) {
330+
break;
331+
}
332+
$classStringObjectType = $paramType->getClassStringObjectType();
333+
if (!$classStringObjectType instanceof TemplateType) {
334+
break;
335+
}
336+
if ($classStringObjectType->getBound()->isSuperTypeOf($objectType)->yes()) {
337+
$objectType = TypeCombinator::intersect($classStringObjectType, $objectType);
338+
}
339+
break;
340+
}
341+
}
342+
}
343+
344+
return $objectType;
312345
}
313346

314347
private function exactInstantiation(MutatingScope $scope, New_ $node, Name $className): Type
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug14188Nsrt;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
interface MyInterface {}
8+
class A implements MyInterface {}
9+
class B implements MyInterface {}
10+
11+
class MyFactory {
12+
/**
13+
* @template T of MyInterface
14+
*
15+
* @param class-string<T> $class
16+
*
17+
* @return T
18+
*/
19+
public function create(string $class) {
20+
if ($class === A::class) {
21+
assertType("'Bug14188Nsrt\\\\A'", $class);
22+
assertType('Bug14188Nsrt\A&T of Bug14188Nsrt\MyInterface (method Bug14188Nsrt\MyFactory::create(), argument)', new $class());
23+
return new $class();
24+
}
25+
26+
return new $class();
27+
}
28+
}

tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1310,4 +1310,9 @@ public function testBug9669(): void
13101310
$this->analyse([__DIR__ . '/data/bug-9669.php'], []);
13111311
}
13121312

1313+
public function testBug14188(): void
1314+
{
1315+
$this->analyse([__DIR__ . '/data/bug-14188.php'], []);
1316+
}
1317+
13131318
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug14188;
4+
5+
interface MyInterface {}
6+
class A implements MyInterface {}
7+
class B implements MyInterface {}
8+
9+
class MyFactory {
10+
/**
11+
* @template T of MyInterface
12+
*
13+
* @param class-string<T> $class
14+
*
15+
* @return T
16+
*/
17+
public function create(string $class) {
18+
if ($class === A::class) {
19+
return new $class();
20+
}
21+
22+
return new $class();
23+
}
24+
}

0 commit comments

Comments
 (0)