Bind unmatched template types to never when inferring an argument against a union of template types#5863
Conversation
…gainst 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.
VincentLanglet
left a comment
There was a problem hiding this comment.
Add a rule test unsuring the right Call to an undefined method are reported in the example
class A {}
class B {}
class A1 extends A {
public function foo() : void {}
}
class B1 extends B {
public function bar() : void {}
}
/**
* @template T1 of A
* @template T2 of B
* @param T1|T2 $type
* @return T1|T2
*/
function f(object $type) {
return new $type();
}
(f(new A1))->foo(); // should pass
(f(new B1))->bar(); // should pass
(f(new A1))->bar(); // should fail
(f(new B1))->foo(); // should fail
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
I've addressed VincentLanglet's review. What I didThe reviewer asked for a rule test ensuring the correct Added:
Verified the test detects the bug by reverting the source fix: without it, the test fails with 4 errors including the bogus ones on the "should pass" lines ( Checks: the new test passes, the full |
Summary
When a function/method had a union of two (or more) template types as a parameter and return type, e.g.
@param T1|T2/@return T1|T2, passing an argument that only satisfies one of the templates produced a widened, impossible return type. Forf(new A1)(whereA1only matchesT1 of A, notT2 of B), PHPStan inferred the return asA1|B— keeping the unresolvableT2at its bound — and additionally reportedUnable to resolve the template type T2. As a result, a perfectly safe call like(f(new A1))->foo()was rejected.This fix makes the unresolvable template types collapse to
never, so the union narrows to just the matched template(s).f(new A1)now returnsA1,(f(new A1))->foo()passes, and(f(new A1))->bar()still correctly fails.Changes
src/Type/UnionType.php—inferTemplateTypes(): when the union is composed entirely ofTemplateTypemembers and the (non-union) received type matches only some of them, the unmatched template members are bound to an explicitNeverTypein the resultingTemplateTypeMap(only applied when at least one member matched, so genuine "nothing matches" cases are left untouched).tests/PHPStan/Analyser/nsrt/bug-2579.php— regression test withassertType()for two- and three-template unions.Analogous cases probed:
T1|T2signature — all shareGenericParametersAcceptorResolver→UnionType::inferTemplateTypes, so all are fixed by this single change (verified withdumpType).A1|A2toT1|T2) — already produced the correctA1|A2, no change needed.list<T1>|list<T2>style wrapped templates — already resolved correctly via the non-template member loop, and intentionally not touched (the fix is scoped to unions of bare template types).Root cause
UnionType::inferTemplateTypes()inferred each template member independently. A template whose bound did not accept the argument (T2 of BagainstA1) simply returned an empty map, leavingT2absent from the inferredTemplateTypeMap.GenericParametersAcceptorResolverthen defaults absent templates toErrorType, whichTemplateTypeHelper::resolveTemplateTypes()resolves to the template's bound — producingA1|Band triggering the "Unable to resolve template type" rule.The correct semantics for a union parameter
T1|T2: an argument that is aT1is simply not aT2, soT2was not provided and contributes nothing to the union — i.e.never. Binding the unmatched members to an explicitnevermakesT1|T2collapse toT1and suppresses the bogus unresolved-template error.Test
tests/PHPStan/Analyser/nsrt/bug-2579.phpasserts:f(new A1())isA1andf(new B1())isB1for@param T1|T2 / @return T1|T2.g(new A1())isA1for a three-template unionT1|T2|T3.The test fails before the fix (inferred
A1|B,A|B1,A1|B|Countable) and passes after. The fullNodeScopeResolverTest,Rules/Generics,CallToFunctionParametersRuleTest,CallMethodsRuleTest, the entiretests/PHPStan/Typeandtests/PHPStan/Rulessuites, and PHPStan self-analysis (make phpstan) all stay green.Fixes phpstan/phpstan#2579