Skip to content

Anchor template types inferred from invariant generic parameters instead of widening them into a union#5941

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-vhndl61
Open

Anchor template types inferred from invariant generic parameters instead of widening them into a union#5941
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-vhndl61

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

When a generic method/function used a template type T in both an invariant
generic parameter (Column<T>) and a plain parameter (T $value), PHPStan
inferred T as the union of every occurrence. So calling

/**
 * @template T
 * @param Column<T> $column
 * @param T $value
 */
public function where($column, string $operator, $value): self {}

$builder->where($board->id /* IntColumn = Column<int> */, '=', 'test_string_value');

produced the confusing error

Parameter #1 $column of method Builder::where() expects Column<'test_string_value'|int>, IntColumn given.

i.e. it blamed the (correct) Column<int> argument and reported a widened
expected type, instead of pointing at the actually incompatible value argument.

The fix makes inference anchor T to the type determined by the invariant
generic argument (T = int), so the diagnostic is now reported on $value:

Parameter #3 $value of method Builder::where() expects int, string given.

This matches what Psalm and Mago report for the same code.

Changes

  • src/Reflection/GenericParametersAcceptorResolver.php
    • Added an anchor pass in resolve(): template types occurring in an invariant
      position are inferred first and substituted into the other parameters before
      the main inference loop, so other occurrences are validated against the
      anchored type rather than widening it into a union.
    • The anchored type is intentionally not substituted back into the
      parameter that anchored it, so template types inferred through a template's
      bound (e.g. @template TModel of ModelInterface<TChild>) keep resolving.
    • Added getInvariantTemplateTypeNames(), which uses
      Type::getReferencedTemplateTypes() (covariant base position) to find
      template types that appear in an invariant position.
  • tests/PHPStan/Analyser/nsrt/generic-object-lower-bound.php and
    tests/PHPStan/Rules/Methods/CallMethodsRuleTest::testGenericObjectLowerBound
    encoded the old union-widening behavior; updated to the corrected behavior
    (the mismatch is now reported on the T $d argument).

Root cause

GenericParametersAcceptorResolver::resolve() combined every per-parameter
template inference with TemplateTypeMap::union(), which unions the inferred
types regardless of the variance of the position they came from. An invariant
generic argument like Column<int> fixes T exactly, but its inference was
unioned with the covariant T $value inference, producing int|string. The
resolved Column<int|string> parameter then failed against the original
Column<int> argument (invariance), so the error landed on the wrong parameter.

The fix recognizes that an invariant position determines the template type
exactly and treats it as an anchor: it is inferred first and the other
occurrences are checked against it. Covariant positions (iterable<T>,
@template-covariant) are deliberately excluded and keep the union behavior,
which is verified by a dedicated regression assertion.

Test

  • tests/PHPStan/Rules/Methods/data/bug-14872.php +
    CallMethodsRuleTest::testBug14872() — the reported reproducer: the two
    mismatching calls now report on $value (expects int, string given /
    expects string, int given), and matching calls report nothing. Verified the
    test fails before the fix (error reported on $column with
    Column<'test_string_value'|int>).
  • tests/PHPStan/Analyser/nsrt/bug-14872.php — asserts the inferred return type
    is anchored (int) for the invariant case, and still widened (1|2|'x') for a
    covariant iterable<T> position, guarding against over-reach.
  • Confirmed functions and static methods are covered by the same shared resolver.

Fixes phpstan/phpstan#14872

…ead of widening them into a union

- GenericParametersAcceptorResolver::resolve() now runs an "anchor" pass before
  the main inference loop: template types that occur in an invariant position
  (e.g. T inside an invariant Column<T>) are inferred first and substituted into
  the other parameters. The remaining occurrences of the same template type are
  then validated against the anchored type instead of being unioned into it.
- As a result, `where(Column<T> $column, T $value)` called with an `IntColumn`
  (`Column<int>`) and a string now reports the mismatch on `$value`
  ("expects int, string given") rather than on `$column`
  ("expects Column<'...'|int>, IntColumn given").
- The anchored type is kept out of the parameter that anchored it, so dependent
  template types inferred through a template's bound (e.g.
  `@template TModel of ModelInterface<TChild>`) are still resolved.
- Covariant positions (e.g. `iterable<T>`, `@template-covariant`) are unaffected
  and keep the existing union-widening behavior.
- Applies uniformly to function, method and static-method calls (shared resolver).
- Added GenericParametersAcceptorResolver::getInvariantTemplateTypeNames() to
  detect invariant template occurrences via getReferencedTemplateTypes().
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.

Generic Error message points to Column<T> instead of the incompatible value

1 participant