Skip to content

Narrow ReflectionClass::getConstant() and ReflectionClass::getConstants() return types based on generic parameter#5534

Closed
phpstan-bot wants to merge 8 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-gi9ip5y
Closed

Narrow ReflectionClass::getConstant() and ReflectionClass::getConstants() return types based on generic parameter#5534
phpstan-bot wants to merge 8 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-gi9ip5y

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When ReflectionClass<T> has a known generic parameter T, the return types of getConstant() and getConstants() can be narrowed from mixed and array<string, mixed> to the actual constant value types of the class.

This adds a DynamicMethodReturnTypeExtension that extracts the template type T from ReflectionClass<T> and uses it to resolve class constant types via ClassReflection.

Changes

  • Added src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php — a DynamicMethodReturnTypeExtension registered via #[AutowiredService] that handles both getConstant() and getConstants() on ReflectionClass
  • getConstant(string $name):
    • Known constant name that exists → returns the constant's exact value type
    • Known constant name that doesn't exist → returns false
    • Dynamic/unknown name → returns union of all constant value types | false
    • Enum cases are returned as EnumCaseObjectType
  • getConstants(?int $filter):
    • Returns a constant array shape array{NAME1: type1, NAME2: type2, ...}
    • Respects the $filter bitmask parameter (IS_PUBLIC, IS_PROTECTED, IS_PRIVATE)
    • Enum cases included as EnumCaseObjectType
  • Falls back to null (default return type) when the generic parameter T is not a known object type

Analogous cases probed

  • ReflectionEnum: Inherits from ReflectionClass, so this extension already handles it. Tested with unit enums, backed enums, and enums with both constants and cases.
  • ReflectionClassConstant::getValue(): ReflectionClassConstant has no generic template type, so narrowing would require tracking which constant the reflection object refers to — left for future work.
  • Visibility filtering: Fully handled via the $filter parameter.
  • Inherited constants: Tested and working — getReflectionConstants() from native reflection includes inherited constants.
  • Interface constants: Tested and working.

Root cause

ReflectionClass::getConstant() returned mixed and getConstants() returned array<string, mixed> regardless of whether the generic type parameter T was known. Since PHPStan already tracks the template type T in ReflectionClass<T> (used by other extensions like ReflectionClassIsSubclassOfTypeSpecifyingExtension), this information can be used to resolve the actual constant types from ClassReflection.

Test

Added tests/PHPStan/Analyser/nsrt/reflection-class-get-constants.php with comprehensive test cases:

  • Known constant names on a class with mixed visibility constants
  • Non-existent constant names returning false
  • Dynamic constant names returning union of all types
  • Unknown generic parameter falling back to mixed
  • Constant array shapes for getConstants()
  • Final class constants
  • Unit enums, backed enums, and enums with both constants and cases
  • Interface constants
  • Inherited constants from parent classes
  • Visibility filtering with IS_PUBLIC, IS_PROTECTED, IS_PRIVATE

Fixes phpstan/phpstan#14532

…tants()` return types based on generic parameter

- Add `ReflectionClassGetConstantsDynamicReturnTypeExtension` that narrows
  return types when the `T` template parameter of `ReflectionClass<T>` is known
- `getConstant('NAME')` returns the exact constant value type when the constant
  exists, `false` when it doesn't, or a union of all constant types | false for
  dynamic names
- `getConstants()` returns a constant array shape with all class constant names
  and their value types
- `getConstants($filter)` respects visibility filter parameter
  (IS_PUBLIC/IS_PROTECTED/IS_PRIVATE)
- Enum cases are handled as `EnumCaseObjectType`, regular constants use their
  value types
- Inherited constants and interface constants are included
- Falls back to default `mixed`/`array<string, mixed>` when the generic
  parameter is unknown

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment on lines +119 to +122
$filterScalars = $filterType->getConstantScalarValues();
if (count($filterScalars) === 1 && is_int($filterScalars[0])) {
$filter = $filterScalars[0];
}
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.

You cannot consider that the filter is null if it's not a single constant value.
Otherwise you'll consider optional array keys as always defined.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed. The fix makes getConstants() array keys optional when the filter argument is a non-constant value (e.g., int $filter), since we can't know which constants the filter will include. Added a test case verifying $ref->getConstants($filter) returns array{A?: 1, B?: 'hello', C?: 3.14, D?: true} instead of treating all keys as always-present.

When getConstants() receives a filter argument that isn't a single
constant integer value, we can't determine which constants will be
included in the result. Mark all keys as optional in this case instead
of treating them as always-defined.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@ondrejmirtes
Copy link
Copy Markdown
Member

ondrejmirtes commented Apr 25, 2026

The logic you have here is flawed. You can't rely on most of these assertions when the class is not final.

Or more precisely, because @template above ReflectionClass is invariant since PHP 8.4+, it's actually okay.

But you should also support ReflectionClass<covariant Foo> where the class name can be a subclass.

All the new logic you wrote in the extension might actually be simplified to synthetic new ClassConstFetch and passing that to Scope::getType().

$filterIsUncertain = false;
if ($filterType !== null) {
$filterScalars = $filterType->getConstantScalarValues();
if (count($filterScalars) === 1 && is_int($filterScalars[0])) {
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.

In case there is multiple constant scalar values we could still determine precisely the return type by iterating on every possible case and returning the Union.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed. The change addresses VincentLanglet's review: when getConstants() receives a filter parameter with multiple constant scalar int values (e.g., IS_PUBLIC|IS_PROTECTED as a union type), we now compute the array result for each filter value separately and return their union (e.g., array{A: 1, B: 'hello'}|array{C: 3.14}) instead of falling back to all-optional keys.

@ondrejmirtes
Copy link
Copy Markdown
Member

Is this really worth it? It's duplicating a lot of logic that's already being done when resolving type of ClassConstFetch so we should have it in a single place.

Comment on lines +77 to +85
foreach ($constantNames as $constantName) {
$name = $constantName->getValue();
if ($classReflection->isEnum() && $classReflection->hasEnumCase($name)) {
$types[] = new EnumCaseObjectType($classReflection->getName(), $name);
} elseif ($classReflection->hasConstant($name)) {
$types[] = $classReflection->getConstant($name)->getValueType();
} else {
$types[] = new ConstantBooleanType(false);
}
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.

You can't rely on most of these assertions when the class is not final.

All the new logic you wrote in the extension might actually be simplified to synthetic new ClassConstFetch and passing that to Scope::getType().

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make phpstan also passes with no errors. Both verification steps are green:

  • make tests: 11,917 tests, 79,427 assertions - all pass
  • make phpstan: 2,297 files analysed, no errors

The commit has been pushed to the PR branch.

@VincentLanglet
Copy link
Copy Markdown
Contributor

Is this really worth it? It's duplicating a lot of logic that's already being done when resolving type of ClassConstFetch so we should have it in a single place.

What do you mean @ondrejmirtes ?

My main goal was initially to bump Phpstorm stubs, but it trigger PHPStan error related to getConstants, like
https://github.com/phpstan/phpstan-src/actions/runs/24934200166/job/73016858879?pr=5533

I don't see a way to avoid this without a dynamicReturnType extension

phpstan-bot and others added 2 commits April 25, 2026 17:13
…t filter values

Instead of making all array keys optional when the filter has multiple
constant scalar values, compute the result for each filter value
separately and return their union.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace manual constant type resolution (enum case checks, getValueType())
with synthetic ClassConstFetch nodes passed to Scope::getType(). This
reuses the existing type resolution in InitializerExprTypeResolver which
already handles enum cases, final/non-final constants, typed constants,
and circular references.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works for

(new ReflectionClass(Foo::class))->getConstant()

but not always when the reflectionClass is passed as an argument since

  • On PHP 8.3- ReflectionClass accepts ReflectionClass
  • You can pass ReflectionClass

…h non-final classes

When the generic parameter T has covariant variance and the reflected class
is not final (or an enum), subclasses may add or override constants, so we
cannot make precise assertions about constant types or shapes. The extension
now detects this via ClassReflection::getCallSiteVarianceMap() and falls
back to the default return type (mixed for getConstant, array<string, mixed>
for getConstants). Final classes and enums remain precise even with covariant
variance since they cannot be subclassed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's a summary of what was done:

Problem: ReflectionClass<covariant Foo> where Foo is not final means the reflected class could be any subclass. The extension was returning precise constant types/shapes that could be wrong since subclasses may add or override constants.

Fix: Added isCovariantWithNonFinalClass() that checks:

  1. Whether any reflected class is non-final (and not an enum)
  2. Whether the ReflectionClass generic parameter T has covariant call-site variance (via ClassReflection::getCallSiteVarianceMap())

When both conditions are true, the extension returns null (falls back to default: mixed for getConstant(), array<string, mixed> for getConstants()).

Preserved behavior:

  • ReflectionClass<Foo> (invariant) still returns precise types
  • ReflectionClass<covariant FinalClass> still returns precise types (can't be subclassed)
  • ReflectionClass<covariant SimpleEnum> still returns precise types (enums can't be extended)

Tests added for all three covariant scenarios (non-final class, final class, enum).

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to still infer

$r = new ReflectionClass(NonFinalClass::class);
$r->getConstant();

since we know for sure it's exactly ReflectionClass and not a child ?

…class

Demonstrates that `new ReflectionClass(NonFinalClass::class)` returns
precise constant types since the generic parameter T is invariant, not
covariant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

The answer to VincentLanglet's question is yesnew ReflectionClass(NonFinalClass::class)->getConstant() already returns precise types. The isCovariantWithNonFinalClass check only falls back to mixed when the generic parameter T has covariant call-site variance (e.g., ReflectionClass<covariant Foo>). Direct instantiation yields invariant ReflectionClass<Foo>, so precise types are returned even for non-final classes.

I added test cases demonstrating this: testGetConstantDirectInstantiation() with non-final Foo and testGetConstantDirectInstantiationFinalClass() with FinalClass, both returning precise constant types. All 11,917 tests pass and make phpstan reports no errors.

@staabm staabm deleted the create-pull-request/patch-gi9ip5y branch April 26, 2026 06:23
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.

3 participants