Skip to content

Bind unmatched template types to never when inferring an argument against a union of template types#5863

Open
phpstan-bot wants to merge 2 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-581990r
Open

Bind unmatched template types to never when inferring an argument against a union of template types#5863
phpstan-bot wants to merge 2 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-581990r

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

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. For f(new A1) (where A1 only matches T1 of A, not T2 of B), PHPStan inferred the return as A1|B — keeping the unresolvable T2 at its bound — and additionally reported Unable 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 returns A1, (f(new A1))->foo() passes, and (f(new A1))->bar() still correctly fails.

Changes

  • src/Type/UnionType.phpinferTemplateTypes(): when the union is composed entirely of TemplateType members and the (non-union) received type matches only some of them, the unmatched template members are bound to an explicit NeverType in the resulting TemplateTypeMap (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 with assertType() for two- and three-template unions.

Analogous cases probed:

  • Methods, static methods, generic class instantiation with the same T1|T2 signature — all share GenericParametersAcceptorResolverUnionType::inferTemplateTypes, so all are fixed by this single change (verified with dumpType).
  • Union-received branch (passing A1|A2 to T1|T2) — already produced the correct A1|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 B against A1) simply returned an empty map, leaving T2 absent from the inferred TemplateTypeMap. GenericParametersAcceptorResolver then defaults absent templates to ErrorType, which TemplateTypeHelper::resolveTemplateTypes() resolves to the template's bound — producing A1|B and triggering the "Unable to resolve template type" rule.

The correct semantics for a union parameter T1|T2: an argument that is a T1 is simply not a T2, so T2 was not provided and contributes nothing to the union — i.e. never. Binding the unmatched members to an explicit never makes T1|T2 collapse to T1 and suppresses the bogus unresolved-template error.

Test

tests/PHPStan/Analyser/nsrt/bug-2579.php asserts:

  • f(new A1()) is A1 and f(new B1()) is B1 for @param T1|T2 / @return T1|T2.
  • g(new A1()) is A1 for a three-template union T1|T2|T3.

The test fails before the fix (inferred A1|B, A|B1, A1|B|Countable) and passes after. The full NodeScopeResolverTest, Rules/Generics, CallToFunctionParametersRuleTest, CallMethodsRuleTest, the entire tests/PHPStan/Type and tests/PHPStan/Rules suites, and PHPStan self-analysis (make phpstan) all stay green.

Fixes phpstan/phpstan#2579

…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 VincentLanglet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

I've addressed VincentLanglet's review.

What I did

The reviewer asked for a rule test ensuring the correct Call to an undefined method errors are reported for the PR's example (the assertType() test in nsrt/bug-2579.php proves the inferred type, but not the rule behavior the issue is actually about).

Added:

  1. tests/PHPStan/Rules/Methods/data/bug-2579.php — the example from the issue verbatim (with the foo()/bar() methods), exercising CallMethodsRule:
    • (f(new A1()))->foo() and (f(new B1()))->bar() — should pass
    • (f(new A1()))->bar() and (f(new B1()))->foo() — should fail
  2. testBug2579() in CallMethodsRuleTest.php expecting exactly the two undefined-method errors on lines 30 and 31.

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 (A1|B::foo(), A|B1::bar()) — exactly the issue's actual output. With the fix, only the two intended errors remain.

Checks: the new test passes, the full CallMethodsRuleTest (216 tests) is green, and make phpstan reports no errors. Committed and pushed to the PR branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Impossible template types not rejeced

2 participants