Skip to content

Keep union members of different template types apart in TypeCombinator and infer naked template types from what other union members leave unexplained - #6821

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-7jx5g0i
Open

phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-7jx5g0i

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Since 2.3.0, calling a method with @template T of TKey|TValue and @param (T is object ? T : Box<T>) $value from code holding (TValue is object ? TValue : Box<TValue>) reported Unable to resolve the template type T and an argument.type error. 2.2.x was already wrong here: T silently fell back to its bound, so it reported a return.type error at the call site instead. That error is gone now too.

There were two separate problems:

  1. TypeCombinator::union() merged template arrays of different template types into one. TKey of array|TValue of array became TKey of array. So the else branch Box<T> of the conditional became Box<T of TKey of array>, and Box<TValue> could not match it.
  2. UnionType::inferTemplateTypes() dropped the received members meant for a naked template member. For T|Box<T> receiving X|Box<Y>, Box<T> inferred T = Y and the naked T was skipped, so X was lost.

Changes

  • src/Type/TypeCombinator.php (processArrayTypes(), new groupArrayTypesByTemplateType()): arrays are grouped by template type (name and scope), with non-template arrays in their own group. Each group is processed separately, so only arrays of the same template are merged. Covers TemplateArrayType, TemplateConstantArrayType (TA of array{a: int}|TB of array{a: int} used to collapse to TB), template lists, and template arrays next to plain arrays.
  • src/Type/TypeCombinator.php (compareTypesInUnion()): the iterable merge (string[]|int[] → (string|int)[]) no longer applies to template types. TA of iterable|TB of iterable used to become plain iterable.
  • src/Type/UnionType.php (accepts()): when the array|Traversable reading of a TemplateIterableType is not accepted, the template is checked member by member, the same way isSuperTypeOf() already treats it. Without this, TA|TB (both of iterable) did not accept itself once the union was kept apart. It also fixes (TKey&iterable)|... not being accepted as TKey|TValue, which 2.2.x got wrong too.
  • src/Type/UnionType.php (inferTemplateTypes()): after the non-naked members have inferred, any remaining received member that no non-naked member infers anything from is inferred against the naked template members.
  • turbo-ext/src/TypeCombinator.cpp, turbo-ext/src/UnionType.cpp: the same changes in the native mirrors.
  • Probed and already correct: unions of different templates with non-array bounds (int, int<0, 5>, string, float, bool, object, a class, mixed, constant scalars, int|string).

Root cause

Union simplification and inference both treated template-typed members by their bound. In the array and iterable merges, two different templates with array/iterable bounds were folded into one type. The template-array code kept only the first template's name, which is unsound. The iterable merge widened to plain iterable, which loses the template. Template inference then could not match the narrowed conditional branch. Separately, the union inference rule "a naked template member only infers when no other member does" threw away argument parts that no other member could explain.

Test

  • tests/PHPStan/Rules/Methods/data/bug-15431.php: the issue's playground code, nearly verbatim.
    • CallMethodsRuleTest::testBug15431 expects no errors.
    • ReturnTypeRuleTest::testBug15431 expects only the legitimate error inside unboxValue(): an object TKey might itself be a box. That error was also reported by 2.2.x.
  • tests/PHPStan/Rules/Methods/data/bug-15431-iterable.php / ReturnTypeRuleTest::testBug15431Iterable: template iterables in unions are accepted as their templates.
  • tests/PHPStan/Analyser/nsrt/bug-15431.php:
    • the conditional parameter type keeps both templates
    • T is inferred from conditional arguments
    • unions of template arrays, lists, constant arrays and iterables
    • T|Box<T>, (T is object ? T : Box<T>) and T|array<T> inference from mixed arguments

All new tests fail without the fix. The full suite passes with and without the turbo extension loaded, and make phpstan is clean.

Fixes phpstan/phpstan#15431

🤖 Generated with Claude Code

…or` and infer naked template types from what other union members leave unexplained

- TypeCombinator::processArrayTypes(): group template-typed arrays by name
  and scope and process each group (and the non-template arrays) on its own.
  Previously all-template-array unions were merged under the first template's
  name, so `TKey of array|TValue of array` became `TKey of array` (and
  `TA of array{a: int}|TB of array{a: int}` became `TB ...`). Template
  `list`s and template arrays mixed with plain arrays no longer lose the
  template either.
- TypeCombinator::compareTypesInUnion(): do not merge `iterable` members when
  either is a template type (`TA of iterable|TB of iterable` stayed `iterable`).
- UnionType::accepts(): when the `array|Traversable` reading of a
  TemplateIterableType is not accepted, check the template itself member by
  member, as isSuperTypeOf() already does (`TA|TB` accepts `TA`).
- UnionType::inferTemplateTypes(): received members that no non-naked member
  infers anything from are inferred against the naked template members, so
  `T|Box<T>` receiving `stdClass|Box<array{int}>` gives `T = array{int}|stdClass`
  instead of dropping `stdClass`.
- Ported every change to turbo-ext (TypeCombinator.cpp, UnionType.cpp).
- Probed non-array template bounds (int, int range, string, float, bool,
  object, class, mixed, constant scalars, scalar unions): these already
  stayed apart.

Together these let `@template T of TKey|TValue` with
`(T is object ? T : Box<T>)` resolve T again instead of reporting
"Unable to resolve the template type T".
@ondrejmirtes
ondrejmirtes force-pushed the create-pull-request/patch-7jx5g0i branch from 5c97adf to d90e11b Compare October 27, 2026 18:45
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.

"Unable to resolve the template..." since v2.3.0

1 participant