Skip to content

Keep the narrowed bound of a template type that TemplateTypeHelper::resolveTemplateTypes() maps to itself - #6613

Open
phpstan-bot wants to merge 2 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-wsgtggc
Open

phpstan-bot wants to merge 2 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-wsgtggc

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

A class template bounded to string|int|object used as an array key (array<K, V>) resolved to plain array for methods declared on the class, both for @return array<K, V> and for conditional return types like (K is array-key ? array<K, V> : ...) (the latter regressed in 2.2.16). With this change, Map<string, int>::toArray() is array<string, int> again.

Changes

  • src/Type/Generic/TemplateTypeHelper.php: in resolveTemplateTypes(), when the standin for a template type is the same template type (same name and scope), the occurrence is kept (converted to its argument form if the standin is an argument), instead of being replaced by the declared template type with its wider bound.
  • turbo-ext/src/TemplateTypeHelper.cpp: the same change in the native mirror. The extension was built locally, tests/smoke.php prints ALL OK, and the full test suite passes with the extension loaded. The regression test also passes with the extension loaded and only the PHP change reverted. make bump-turbo still has to run after this commit lands.

Root cause

TypeNodeResolver makes the key of array<K, V> safe by intersecting it with int|string. For K of string|int|object, that produces a K of int|string occurrence. Methods, parameters and properties of a class are then resolved against the class's active template type map. Outside a generic instantiation, that map sends K to the declared K of int|string|object, which widened the key's bound again. TypehintHelper::decideType() resolves the PHPDoc type to its bounds (array<int|object|string, mixed>), finds it is not a subtype of the native array, and falls back to the native type. So every PHPDoc type with such a key went through the same substitution: return types, parameters, @var properties and promoted properties. Before 8411207 the conditional-return variant happened to be re-normalized and hid this.

Other places I checked:

  • Function- and method-level templates were already correct, because they are never resolved against a class map.
  • Inherited interface methods (@extends) and trait methods (@use) are covered by the fix, and the test includes them.

Test

tests/PHPStan/Analyser/nsrt/bug-15321.php covers:

  • the issue's reproducer: a conditional return type and a plain array<K, V>
  • non-empty-array<K, V>
  • inside the class: an @param, an @var property and a promoted property
  • a generic class instantiated from outside
  • inherited interface methods
  • trait methods

Before the fix, the issue's reproducer, non-empty-array, the parameter, the @var property and the instantiated-class assertions failed with array or with a widened K of int|object|string key.

Fixes phpstan/phpstan#15321

🤖 Generated with Claude Code

…resolveTemplateTypes()` maps to itself

- In the key of `array<K, V>`, TypeNodeResolver narrows `K of string|int|object`
  to a `K of int|string` occurrence. Methods, parameters and properties of the
  declaring class are then resolved against the class's active template type
  map, which maps `K` to the declared `K` and so widened the key bound back to
  `int|string|object`. `TypehintHelper::decideType()` then found the PHPDoc
  type not to be a subtype of the native `array` and fell back to `array`.
- When the standin is the same template type (same name and scope),
  resolveTemplateTypes() now keeps the occurrence (converted to its argument
  form when the standin is an argument), so the narrowed bound survives.
- Mirrored the change in turbo-ext/src/TemplateTypeHelper.cpp.
- Covered: plain and conditional return types, non-empty-array, @PARAM,
  @var and promoted properties inside the class, inherited interface methods
  and trait methods. Function-level templates were not affected (they are
  not resolved against a class map).
@staabm

staabm commented Sep 27, 2026

Copy link
Copy Markdown
Contributor

//cc @SanderMuller

@SanderMuller

Copy link
Copy Markdown
Contributor

I checked this against 2.3.x 3bc4b93d6.

  • At level 10, as on the playground, the reporter's snippet gives array twice on 2.3.x and array<string, int> twice with this PR. That holds with and without the turbo extension (71cb8ca on base, 27c52c5 here, diagnose says enabled for both).
  • bug-15321.php fails on 2.3.x. With only the PHP change reverted, it fails without turbo and passes with 27c52c5 loaded. TurboExtensionEnabler::isActive() is true under the test bootstrap, so the C++ change carries the fix by itself. The bump commit matches: 27c52c5 is the last commit that touches turbo-ext/src.
  • make tests (22368 tests) passes without turbo and with 27c52c5 loaded. make phpstan and phpcs are clean.

To look for over-reach, I compared all errors before and after on real generic code. Tempest (1519 files, level 8) reports the same 2821 errors, and laravel/framework's laravel-types.neon the same 6. On Tempest the errors are also identical with and without turbo on this PR.

I also tried a collection class that uses its own array<K, V> property, with K of string|int|object. This PR removes two wrong errors that 2.3.x reports there:

  • $items type has no value type specified
  • all() should return array<K of int|string, V> but returns array

It adds one: for $this->items[$key] = $value with a K $key, $items (array<K of int|string, V>) does not accept array<K of int|object|string, V>. That line can write an object key, and 2.3.x already reports it as Possibly invalid array key type int|object|string. With an is_object($key) guard, or with is_int($key) || is_string($key), the key narrows to K of int|string and neither error appears. So I see the new error as correct, just a second error on a line that is already reported.

Performance: Tempest cold with turbo, 3 interleaved rounds, takes 19.1-19.8 s CPU on 2.3.x and 19.2-19.4 s here. I see no difference.

Two questions:

  • The regression shipped in 2.2.16: 8411207 is on 2.2.x. Should this go to 2.2.x as well, for a patch release?
  • The description still says "make bump-turbo still has to run after this commit lands", but 0686cae already does the bump.

CI: 13 of the 15 red checks also fail on #6609, #6604, #6611 or #6612 today. The other two are the Mutation Testing jobs. The runner cancelled both with a shutdown signal while Infection was still generating mutants, so they have no result yet and need a re-run.

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.

Bounded template type in conditional return type resolves to array since 2.2.16

3 participants