Skip to content

Erase generic type arguments when comparing a class-string against a constant class-string - #6485

Open
phpstan-bot wants to merge 6 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-9n3wj48
Open

phpstan-bot wants to merge 6 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-9n3wj48

Conversation

@phpstan-bot

@phpstan-bot phpstan-bot commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

A union of parameterized class-string types was not narrowed after a class name was excluded with !==: class-string<X<int>>|class-string<Y<int>> stayed unchanged instead of narrowing to class-string<Y<int>>, while the equivalent union of raw class-string<X>|class-string<Y> narrowed correctly. Star-projected class-string<X<*>> was affected the same way.

The fix erases generic type arguments before a class-string is compared against a constant class-string, because a class-string value only ever carries a class name.

Changes

  • src/Type/Generic/GenericClassStringType.php
    • New @internal static isValueOfGenericType(Type $genericType, string $className): IsSuperTypeOfResult holding the "can a class named $className be the value behind class-string<$genericType>" check that was previously duplicated in two places.
    • New private eraseTypeArguments() which maps every GenericObjectType inside the generic type to its unparameterized ObjectType counterpart (keeping the subtracted type) via TypeTraverser.
    • isSuperTypeOf() now delegates its ConstantStringType branch to the new helper.
  • src/Type/Constant/ConstantStringType.php
    • The GenericClassStringType branch of isSuperTypeOf() delegates to the same helper instead of repeating the StaticType/TemplateType/ObjectType dance.

Analogous cases that were broken by the same root cause and are fixed by this change (each covered by a new assertion):

  • star projections — class-string<X<*>>|class-string<Y<*>>
  • a union nested inside one class-stringclass-string<X<int>|Y<int>>
  • === narrowing, which used to produce a stray 'X'&class-string<X<int>> intersection instead of 'X'
  • switch / default arm narrowing
  • in_array($class, [X::class], true) narrowing
  • match exhaustiveness: "Match expression does not handle remaining values" false positive
  • constant-array offset access keyed by class names: $map[$class] reported "Offset ... does not exist" and produced *ERROR*, now 1|2 and "might not exist" (same as for raw class-string<X>)
  • TypeCombinator::union() now absorbs 'X' into class-string<X<int>>
  • class-string<X<int>>&literal-string now subtracts to *NEVER* like its raw counterpart

Probed and found already correct, so no change and no test kept: GenericClassStringType::accepts() (an accepts context already treats an unparameterized class name as compatible with any parameterization), is_a() / is_subclass_of() narrowing, instanceof $class, and template type inference from X::class.

Root cause

ConstantStringType::isSuperTypeOf() and GenericClassStringType::isSuperTypeOf() both answered "is C::class a possible value of class-string<G>?" by turning the constant into new ObjectType(C) and requiring G->isSuperTypeOf(ObjectType(C)) to be a definite Yes. The Yes requirement is deliberate — it filters out the uncertainty that ObjectType(C) could really be a subclass of C.

But when G is parameterized, GenericObjectType::isSuperTypeOf() can never return Yes for an unparameterized ObjectType(C): it looks up C's ancestor with the same class name, finds a plain ObjectType, and downgrades the result to Maybe because the type arguments are unknown. So the check collapsed to No, TypeCombinator::remove() bailed out at its isSuperType->no() early return and never reached GenericClassStringType::tryRemove(), and no narrowing happened.

A class-string value is just a class name and cannot carry type arguments, so that particular uncertainty must not count against the match. The fix erases the type arguments from the generic type before the comparison, which makes class-string<X<int>> and class-string<X<*>> behave exactly like class-string<X> in this comparison. Erasure — rather than switching to an accepts context — is what keeps the existing precision for subtracted object types such as class-string<Type~(ConstantIntegerType|IntegerRangeType)>, where the Maybe genuinely comes from the subtraction and not from type arguments.

Test

  • tests/PHPStan/Analyser/nsrt/bug-15266.php — the playground reproducer verbatim (parameterized, star-projected and raw unions), plus one function per analogous case listed above. All 11 new assertions fail without the source change.
  • tests/PHPStan/Rules/Comparison/data/bug-15266.php and MatchExpressionRuleTest::testBug15266() — a match over a parameterized/star-projected class-string union is expected to report nothing; without the fix it reports "Match expression does not handle remaining values" twice.

Fixes phpstan/phpstan#15266
Fixes phpstan/phpstan#10498

Comment thread src/Type/Generic/GenericClassStringType.php Outdated
@VincentLanglet VincentLanglet self-assigned this Sep 20, 2026

@staabm staabm 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.

lgtm - issue bot reports more stuff,.. not sure its related or an outdated result list

@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 non regression test for

Comment thread tests/PHPStan/Rules/Comparison/data/bug-15266.php Outdated
phpstan-bot and others added 4 commits September 22, 2026 18:33
…a constant class-string

- A `class-string` value carries only a class name, never type arguments, so
  `GenericObjectType::isSuperTypeOf()` returning `Maybe` for an unparameterized
  class name must not make `'X'` a non-member of `class-string<X<int>>`.
- Added `GenericClassStringType::isValueOfGenericType()`, which strips generic
  type arguments (via `TypeTraverser`) from the generic type before comparing it
  against `ObjectType($className)`, and used it from both places that did the
  constant class-string comparison by hand: `GenericClassStringType::isSuperTypeOf()`
  and `ConstantStringType::isSuperTypeOf()`.
- Subtracted object types keep their precision because erasure only replaces
  `GenericObjectType` with its unparameterized `ObjectType` counterpart.
- Fixes the same family of misses that all funnelled through those two methods:
  `class-string<X<*>>` star projections, unions nested inside a single
  `class-string<X<int>|Y<int>>`, `===` narrowing (which produced a stray
  `'X'&class-string<X<int>>` intersection), `switch`/`default` narrowing,
  `in_array()` narrowing, `match` exhaustiveness false positives, constant-array
  offset access keyed by `X::class` (reported "Offset does not exist" and
  `*ERROR*`), and `TypeCombinator::union()` failing to absorb `'X'` into
  `class-string<X<int>>`.
- Probed and found already correct: `GenericClassStringType::accepts()` (an
  accepts context already ignores the type arguments of an unparameterized class
  name), `is_a()`/`is_subclass_of()` narrowing, `instanceof $class`, and template
  type inference from `X::class`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@VincentLanglet
VincentLanglet force-pushed the create-pull-request/patch-9n3wj48 branch from 0379b43 to 4102d83 Compare September 22, 2026 16:34

@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.

Fix the parity in the Turbo extension files .cpp

phpstan-bot and others added 2 commits September 22, 2026 17:07
`isValueOfGenericType()` and its type argument erasure landed in the PHP
GenericClassStringType, but both it and ConstantStringType are shadowed by
the turbo extension, so the native mirrors still ran the old comparison —
with the extension loaded the fix had no effect.

GenericClassStringType.cpp gains the static method (registered under the
twin's generated signature, so a `self::` call from PHP reaches it) with the
erasure as a stateless native TypeTraverser::map() callback: a
GenericObjectType becomes the ObjectType of the class it names, keeping the
subtracted type. Its isSuperTypeOf() delegates to it, and
ConstantStringType.cpp reaches the same body through the new
pt_generic_class_string_is_value_of_generic_type() entry point.

The ObjectType of the compared class name is built before the traversal:
the twin takes `$className` by value, while the native borrows it from a
property slot that the calls in between must not outlive.

type-family.php covers the new method directly over an object, a
parameterized, star-projected, subtracted and union generic type, a
StaticType and a TemplateType, and adds parameterized, star-projected and
union class-strings to the string family's subjects and comparison targets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Done. The review asked for turbo-ext parity, and both classes this PR touched (ConstantStringType, GenericClassStringType) are #[ShadowedByTurboExtension] — with the extension loaded, the fix had no effect because the native mirrors still ran the old comparison.

740aaebd5 — Mirror the class-string type argument erasure in the native Type classes

  • turbo-ext/src/GenericClassStringType.cpp — native static isValueOfGenericType(), registered under the twin's generated signature so a self:: call from PHP reaches it. The erasure is a stateless native TypeTraverser::map() callback: a GenericObjectType becomes the ObjectType of the class it names, keeping the subtracted type (slot reads when the object is exactly the native class, getClassName()/getSubtractedType() otherwise, since a subclass may override them). isSuperTypeOf() delegates to it.
  • turbo-ext/src/ConstantStringType.cpp — its GenericClassStringType branch reaches the same body via the new pt_generic_class_string_is_value_of_generic_type() entry point declared in TypeTraits.h.
  • turbo-ext/src/generated/GenericClassStringType.h — regenerated for the new method.
  • turbo-ext/tests/type-family.php — differential coverage: isValueOfGenericType() directly over an object / parameterized / star-projected / subtracted / union generic type, a StaticType and a TemplateType; plus parameterized, star-projected, bare and union class-strings as string-family subjects and comparison targets.

One deliberate deviation from the twin's statement order: the ObjectType of the compared class name is built before the traversal. PHP takes $className by value; the native borrows it from a property slot that the intervening calls must not outlive. Nothing observable depends on the order — the constructor only stores the name.

18003c71c — Bump expected turbo version (make bump-turbo, 3ff623f740aaeb).

Verification: strict build clean under the CI warning set; smoke.php ALL OK; side-by-side.php method parity clean; signature-parity.php OK (14059 methods); make tests 22171 tests / 97813 assertions OK; the same suite with the extension loaded gives identical counts; make phpstan no errors; analysis output over src/Type/Generic, src/Type/Constant and the reproducer is byte-identical with the extension on and off; and the issue's narrowing (class-string<X<int>>|class-string<Y<int>> minus X::classclass-string<Y<int>>) is identical either way.

Two things I could not run here:

  • make lint-turbo pins clang-tidy 21; only 18 is installed on this machine and the target refuses a mismatched major. CI covers it.
  • side-by-side.php reports 52 generated headers as stale, all pre-existing and untouched by this PR — this machine's PHP emits self where the committed headers carry resolved class names, so I regenerated and kept only GenericClassStringType.h (whose added lines use fully-qualified names and are identical under either PHP).

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

Labels

None yet

Projects

None yet

3 participants