From fd758760bfbd5daf2a1b04b1821137cb1db04999 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:21:16 +0000 Subject: [PATCH 1/2] Bind unmatched template types to `never` when inferring an argument against a union of template types - In `UnionType::inferTemplateTypes()`, detect when the union consists entirely of template types (e.g. `T1|T2`) and the received argument type matches only some of them. - The template types that cannot be inferred from the argument are now bound to an explicit `never` instead of being left unresolved (which defaulted them to their bound and produced spurious `A1|B` return types plus "Unable to resolve the template type" errors). - For `@param T1|T2 $x` / `@return T1|T2`, passing a `T1` now infers `T2 = never`, so the return narrows to `T1` and the union disappears. - Shared inference path means methods, static methods and generic class instantiation with the same `T1|T2` signature are fixed too. --- src/Type/UnionType.php | 32 ++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-2579.php | 42 ++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-2579.php diff --git a/src/Type/UnionType.php b/src/Type/UnionType.php index 12f0d540ba..3388d0e4a0 100644 --- a/src/Type/UnionType.php +++ b/src/Type/UnionType.php @@ -1234,6 +1234,38 @@ public function inferTemplateTypes(Type $receivedType): TemplateTypeMap $myTypes = $this->types; } + // When every member of the union is a template type (e.g. T1|T2) and the received + // type only matches some of them, the unmatched template types cannot be inferred + // from this argument. Bind them to never so the union narrows to the matched members + // instead of widening each unmatched template to its bound. + if (count($myTypes) > 1) { + $templateMembers = []; + foreach ($myTypes as $type) { + if (!$type instanceof TemplateType) { + $templateMembers = []; + break; + } + $templateMembers[] = $type; + } + + if (count($templateMembers) > 0) { + $matchedTypes = TemplateTypeMap::createEmpty(); + $unmatchedNames = []; + foreach ($templateMembers as $type) { + $inferred = $type->inferTemplateTypes($receivedType); + if ($inferred->isEmpty()) { + $unmatchedNames[] = $type->getName(); + continue; + } + $matchedTypes = $matchedTypes->union($inferred); + } + + if (!$matchedTypes->isEmpty() && count($unmatchedNames) > 0) { + return $matchedTypes->union(new TemplateTypeMap(array_fill_keys($unmatchedNames, new NeverType(true)))); + } + } + } + foreach ($myTypes as $type) { if ($type instanceof TemplateType || ($type instanceof GenericClassStringType && $type->getGenericType() instanceof TemplateType)) { continue; diff --git a/tests/PHPStan/Analyser/nsrt/bug-2579.php b/tests/PHPStan/Analyser/nsrt/bug-2579.php new file mode 100644 index 0000000000..f3572b4c4c --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-2579.php @@ -0,0 +1,42 @@ + Date: Sat, 13 Jun 2026 22:31:05 +0000 Subject: [PATCH 2/2] Add rule test for undefined method calls on union template return types Co-Authored-By: Claude Opus 4.8 --- .../Rules/Methods/CallMethodsRuleTest.php | 17 ++++++++++ tests/PHPStan/Rules/Methods/data/bug-2579.php | 32 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 tests/PHPStan/Rules/Methods/data/bug-2579.php diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index a6e55ecbd3..9bcdb47767 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -987,6 +987,23 @@ public function testBug14720(): void $this->analyse([__DIR__ . '/data/bug-14720.php'], []); } + public function testBug2579(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-2579.php'], [ + [ + 'Call to an undefined method Bug2579Methods\A1::bar().', + 30, + ], + [ + 'Call to an undefined method Bug2579Methods\B1::foo().', + 31, + ], + ]); + } + public function testClosureBind(): void { $this->checkThisOnly = false; diff --git a/tests/PHPStan/Rules/Methods/data/bug-2579.php b/tests/PHPStan/Rules/Methods/data/bug-2579.php new file mode 100644 index 0000000000..eafd8419b4 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-2579.php @@ -0,0 +1,32 @@ +foo(); // should pass + (f(new B1()))->bar(); // should pass + + (f(new A1()))->bar(); // should fail + (f(new B1()))->foo(); // should fail +}