Skip to content

Resolve type arguments of generic static types inside the class, in properties and in class constants - #6826

Merged
ondrejmirtes merged 3 commits into
2.3.xfrom
generic-static-object-type
Oct 24, 2026
Merged

ondrejmirtes merged 3 commits into
2.3.xfrom
generic-static-object-type

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

static<...> lost its type arguments in several places. This fixes them at the root and extends the fix to properties and class constants.

Generic static type's class reflection – GenericStaticType::getStaticObjectType() and getClassReflection() handed out the declaring class's reflection with unresolved template types. Members of a static<int, string> then resolved against TKey/TValue whenever the type was used inside the class. This covers $this->concrete()->all(), @param static<...>, static:: calls and properties, new static(...) with inferred type arguments, promoted properties typed by the constructor's @param, and final classes. getAncestorWithClassName() now keeps the type arguments for parent classes and interfaces, where it used to fall back to their bounds.

This reverts the source change from #6695 (a workaround for method calls from outside the class) and keeps its tests. Three existing expectations pinned the bug (bug-4590, pr-5880, bug-12575); the first commit's message describes each one.

Properties – CalledOnTypeUnresolvedPropertyPrototypeReflection replaced a static<ANode> property type with the fetched-on NodeCursor<BNode>. It now handles GenericStaticType the same way the method counterpart does. This brings in the test from #6799 (HawkForge is credited as co-author) and supersedes that PR.

Class constants – static in a constant's PHPDoc type stayed bound to the declaring class. For example, $child::IMPL declared as class-string<static> on the parent was class-string<static(Base)>. It is now resolved against the class the constant is fetched on, like properties and methods.

The turbo C++ mirrors (GenericStaticType, the two prototype reflections and InitializerExprTypeResolver) are ported. Analysis output is byte-identical with the extension on and off.

Closes phpstan/phpstan#15378
Closes phpstan/phpstan#12680

🤖 Generated with Claude Code

https://claude.ai/code/session_011i2xTiyrXgfVkG1tmwMtUM

ondrejmirtes and others added 3 commits October 24, 2026 22:06
GenericStaticType::getStaticObjectType() handed the class reflection of the
declaring class, whose template types are unresolved, to the
GenericObjectType it builds, and getClassReflection() returned that
reflection too. Members of a `static<int, string>` then resolved against
`TKey`/`TValue` instead of `int`/`string` whenever the type was used inside
the class - `$this->concrete()->all()`, a `@param static<...>` parameter,
`static::` calls and properties, `new static(...)` with inferred type
arguments, promoted properties typed by the constructor's `@param`, and
every `static<...>` of a final class.

getClassReflection() now returns the reflection with the type arguments,
like GenericObjectType does, and getStaticObjectType() builds on it.
getAncestorWithClassName() keeps the type arguments the static object type
has for the ancestor class: through changeBaseClass(), which can only carry
them down to a subclass, an ancestor got its template bounds instead.

This replaces the workaround in
CalledOnTypeUnresolvedMethodPrototypeReflection that rebuilt the object type
without the reflection for method calls from outside the class (#6695); its
tests are kept.

Three expectations pinned the bug:
- bug-4590: `return new static(["ok" => "hello"])` from a method returning
  `static<array<string, string>>` now infers `T` from the return type, like
  `return new OkResponse([...])` in the same file already did. A real
  mismatch is added instead.
- pr-5880: after `@phpstan-assert-if-true static<A> $this`, `$this->get()`
  is `A` and `->a()` exists.
- bug-12575: after `@phpstan-self-out static<T&U>`, `$this->getT()` is
  `A&T`.

The native GenericStaticType declares its properties from the generated
declarations now, so the new memo property keeps the slots in step with the
PHP twin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011i2xTiyrXgfVkG1tmwMtUM
CalledOnTypeUnresolvedPropertyPrototypeReflection replaced every StaticType,
GenericStaticType included, with the fetched-on type, so the type arguments
written in `@var static<...>` were dropped: on `NodeCursor<BNode>`, a property
declared `static<ANode>` was read as `NodeCursor<BNode>`. This handles
GenericStaticType like CalledOnTypeUnresolvedMethodPrototypeReflection does
for method return types.

The bug-15378 regression test comes from #6799.

Co-Authored-By: HawkForge <75696486+HawkForge@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011i2xTiyrXgfVkG1tmwMtUM
A class constant's declared type kept `static` bound to the declaring class:
`$child::IMPL` with `@var class-string<static>` declared on the parent was
`class-string<static(Base)>`, and so was `static::IMPLS` inside the child.
Unlike properties and methods, constants had no counterpart of the
called-on prototype reflections that resolve `static` against the class the
member is accessed on.

`static` now becomes the fetched-on type: an object type for `$object::X`,
a `static` of the current class for `static::X` and `$this::X`, and
a `static<...>` takes its type arguments over to the fetched-on class.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011i2xTiyrXgfVkG1tmwMtUM
@ondrejmirtes
ondrejmirtes merged commit a5b2cc8 into 2.3.x Oct 24, 2026
992 of 993 checks passed
@ondrejmirtes
ondrejmirtes deleted the generic-static-object-type branch October 24, 2026 20:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant