Skip to content

Make BackedEnum generic - #6273

Open
JanTvrdik wants to merge 1 commit into
phpstan:2.2.xfrom
JanTvrdik:generic-backed-enum
Open

Make BackedEnum generic#6273
JanTvrdik wants to merge 1 commit into
phpstan:2.2.xfrom
JanTvrdik:generic-backed-enum

Conversation

@JanTvrdik

@JanTvrdik JanTvrdik commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #6272, but a lot more involved.

BackedEnum now carries its backing type:

/**
 * @template-covariant T of int|string = int|string
 */
interface BackedEnum extends UnitEnum
{
	/** @var T */
	public readonly int|string $value;
	...
}
/** @param BackedEnum<string> $e */
function f(BackedEnum $e): void {
	$e->value;  // string, was int|string
}

/**
 * @template T of BackedEnum<string>
 * @param T $e
 * @return value-of<T>
 */
function g(BackedEnum $e) { return $e->value; }  // string, was int|string

The implicit implements problem

Unlike SensitiveParameterValue, BackedEnum cannot get its type argument from a call site or from an @implements tag — PHP implements the interface implicitly, so no tag exists to carry it. ClassReflection::getImmediateInterfaces() fills it in from the enum backing type instead.

Two supporting changes were needed to make that stick:

  • ClassReflection::getInterfaces() and ObjectType::getAncestorWithClassName() now prefer the interface as resolved on the class itself over the same interface reached through one of its ancestors. Without this, enum E: string implements HasLabel (where interface HasLabel extends BackedEnum) resolved BackedEnum through HasLabel and lost the string.
  • A generic ancestor with no @implements/@extends tag now uses the declared template defaults instead of error types, when every template type has a default. This is what makes interface Foo extends BackedEnum resolve to BackedEnum<int|string> rather than BackedEnum<*ERROR*>.

from() and tryFrom()

They take a method-level @template TValue of T rather than @param T $value. @param T would put a covariant type parameter in contravariant position, which VarianceCheck forbids in user code — PHPStan should not ship a stub doing what its own rule rejects. The method-level template keeps T in a bound only, and still reports:

/** @param BackedEnum<int> $e */
function f(BackedEnum $e, string $s): void {
	$e::from($s);  // Parameter #1 $value of static method BackedEnum<int>::from()
	               // expects TValue of int, string given.
}

@return stays static. Feeding TValue into the return type (BackedEnum<TValue>, or static&BackedEnum<TValue>) narrows T to the argument literal, which is wrong — T is the enum's declared backing type, not the value of one instance. It makes this a false positive:

$x = $bare::from('a');  // BackedEnum<'a'>
$x::from('b');          // reported, but perfectly legal at runtime

It also degrades S::from($s) from S to BackedEnum<string>.

The stub preserves the @throws ValueError / @throws TypeError tags from phpstorm-stubs — stub docblocks replace them wholesale, and dropping them made SE::from($x); statements report resultUnused and turned catch (ValueError|TypeError) around from() into dead catches.

Side note: static&BackedEnum<TValue> turned out to be a no-op anyway. Intersecting a bare ObjectType('BackedEnum') with GenericObjectType('BackedEnum', ['a']) keeps the bare one instead of the generic one. That looks like a TypeCombinator quirk unrelated to this PR; I have not touched it.

@implements BackedEnum<int> is now a valid annotation

Because the interface is implemented implicitly, EnumAncestorsRule previously rejected the tag with "has @implements tag, but does not implement any interface" — even though a backed enum genuinely implements BackedEnum. It now accepts the tag, and reports a new enum.implementsBackingType error when the tag contradicts the actual backing type:

/** @implements BackedEnum<string> */
enum Wrong: int {} // The @implements tag of enum Wrong specifies BackedEnum<string>
                   // but the enum is backed by int.

The tag is redundant for inference (the backing type always wins), but downstream code annotated this way — notably the ecosystem around shipmonk/phpstan-rules' BackedEnumGenericsRule, which simulated generic BackedEnum in userland — gets a working migration path instead of a hard error.

Backward compatibility

Three properties keep this from breaking existing code:

  • T defaults to int|string, so a bare BackedEnum in PHPDoc keeps its old meaning and is not reported as missing type arguments on level 6 and above.
  • T is covariant, so every backed enum stays assignable to a bare BackedEnum.
  • The type argument is implicit, so nobody has to write anything.

New errors appear only once someone opts in by writing BackedEnum<string>.

Measured on carbon, symfony/console + http-foundation + validator, doctrine/dbal, league/csv, monolog, guzzle and brick/money — 29 enum declarations, level 9, 6218 errors before the change: 0 new, 0 gone. The single delta is message wording: calls through the interface now read … BackedEnum<int|string>::from() expects TValue of int|string, mixed given instead of … BackedEnum::from() expects int|string, mixed given, so baseline entries matching the old wording for such calls need regenerating (concrete-enum messages are unchanged).

Known ecosystem impact, verified via this repo's integration matrix (everything else that is red there fails identically for sibling PRs):

  • phpstan-doctrine on PHP ≤ 8.0: its compatibility/BackedEnum.stub now collides with the bundled stub (Class BackedEnum declared multiple times). Needs a phpstan-doctrine release dropping (or version-guarding) that stub.
  • shipmonk/phpstan-rules' BackedEnumGenericsRule activates (it fires when the BackedEnum ancestor isGeneric()), demanding @implements tags. The rule's own suggestion now works thanks to the EnumAncestorsRule change above, and the userland emulation is planned to be dropped in favour of this native support.

One more behavioural note: ObjectType::getAncestorWithClassName() now prefers the interface as resolved on the class itself; in ambiguous diamonds (class C implements A, B where A extends I<int>, B extends I<string>) the resolution can change — it now matches what ClassReflection::getAncestorWithClassName() already did.

The sharpest general edge is the template-defaults change: for any generic class with defaults that is extended without a tag, the argument goes from *ERROR* (permissive) to the default (stricter). The blast radius is small — FFI\CData is the only bundled stub that declares template defaults at all.

Two behaviours worth calling out for review:

  • interface Foo extends BackedEnum without @extends BackedEnum<string> resolves to BackedEnum<int|string>, so Foo is not accepted where BackedEnum<string> is expected. A one-line @extends tag fixes it, but it is a real papercut for existing interfaces (pinned in the acceptance rule test).
  • StringEnum::from($int) on a concrete enum is still unchecked — the enum's own native from(int|string) wins over the inherited stub template. That gap predates this PR and would need a separate rule.
  • An enum whose interface claims a different backing type (enum Wrong: int implements StringBacked where StringBacked has @extends BackedEnum<string>) resolves silently to BackedEnum<int> — the actual backing type wins; the interface-level contradiction is not yet diagnosed.

Copilot AI lite review requested due to automatic review settings August 26, 2026 09:28
@JanTvrdik
JanTvrdik force-pushed the generic-backed-enum branch from b7dd913 to 5b921b1 Compare August 26, 2026 09:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR extends PHPStan’s understanding of PHP 8.1 backed enums by making BackedEnum generic over its backing type (int|string), and updating core type/reflection resolution so that the correct type argument is preserved through interface inheritance and value-of<...> evaluation.

Changes:

  • Introduces a generic BackedEnum<T of int|string = int|string> stub and wires it into the default stub set.
  • Adjusts interface/ancestor resolution (ClassReflection::getInterfaces() and ObjectType::getAncestorWithClassName()) to prefer the interface as resolved on the concrete class over the same interface reached via ancestors.
  • Updates ValueOfType logic and adds regression coverage (nsrt + rule test) for backing-type-aware behavior and static calls to from()/tryFrom().

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/PHPStan/Rules/Methods/data/generic-backed-enum.php Adds rule-test data validating from()/tryFrom() parameter checking with BackedEnum<T>.
tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php Adds a new test asserting the expected static-call errors for generic BackedEnum.
tests/PHPStan/Analyser/nsrt/generic-backed-enum.php Adds nsrt coverage for generic backing-type propagation, interface inheritance, and value-of<T>.
stubs/BackedEnum.stub Defines the generic BackedEnum stub (template + backing-type-aware $value).
src/Type/ValueOfType.php Improves value-of<TemplateType of BackedEnum> resolution by extracting BackedEnum<T>’s active T.
src/Type/ObjectType.php Adjusts ancestor lookup to prefer directly-resolved interfaces over those reached through other ancestors.
src/Reflection/ClassReflection.php Ensures directly-resolved interfaces override inherited ones; injects implicit BackedEnum<T> for backed enums; uses template defaults for unspecified generic ancestors when fully defaulted.
conf/config.neon Registers the new BackedEnum.stub in the default stub list.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Type/ObjectType.php
Comment on lines +1874 to 1885
// An interface resolved on this class itself is more specific than the same
// interface reached through one of its ancestors.
foreach ($this->getInterfaces() as $interface) {
if ($interface->getClassName() !== $className) {
continue;
}

return self::$ancestors[$description][$className] = $this->currentAncestors[$className] = $interface;
}

foreach ($this->getInterfaces() as $interface) {
$ancestor = $interface->getAncestorWithClassName($className);
$this->analyse([__DIR__ . '/data/bug-15002.php'], []);
}

#[RequiresPhp('>= 8.1')]
@JanTvrdik
JanTvrdik force-pushed the generic-backed-enum branch from 5b921b1 to 427eca1 Compare August 26, 2026 09:40
`BackedEnum` now carries the backing type: `@template-covariant T of int|string`.
`BackedEnum<string>::$value` is `string` instead of `int|string`, and
`value-of<T>` for `@template T of BackedEnum<string>` resolves to `string`.

`from()` and `tryFrom()` take a method-level `@template TValue of T` instead of
`@param T`. `T` stays out of contravariant position, which the variance check
forbids for a covariant type parameter, while `BackedEnum<int>::from('a')` is
still reported. `@throws` tags from phpstorm-stubs are preserved so that the
stub does not silence result-unused and dead-catch analysis around `from()`.

Three things keep this backwards compatible:

* `T` defaults to `int|string`, so a bare `BackedEnum` in PHPDoc keeps its old
  meaning and is not reported as missing type arguments on level 6 and above.
* `T` is covariant, so every backed enum stays assignable to a bare
  `BackedEnum`.
* PHP implements `BackedEnum` implicitly, so there is no `@implements` tag that
  could carry the type argument. `ClassReflection::getImmediateInterfaces()`
  fills it in from the enum backing type instead.

Two supporting changes were needed for that last point:

* `ClassReflection::getInterfaces()` and `ObjectType::getAncestorWithClassName()`
  now prefer the interface as resolved on the class itself over the same
  interface reached through one of its ancestors. Without this, an enum that
  implements an interface which extends `BackedEnum` resolved to the ancestor
  type arguments of that interface.
* A generic ancestor with no `@implements` or `@extends` tag now uses the
  declared template defaults instead of error types, when every template type
  has a default. This is what makes `interface Foo extends BackedEnum` resolve
  to `BackedEnum<int|string>` rather than `BackedEnum<*ERROR*>`.

Because the interface is implemented implicitly, `EnumAncestorsRule` now
accepts an `@implements BackedEnum<int>` tag on a backed enum instead of
reporting that the enum "does not implement any interface", and reports
`enum.implementsBackingType` when the tag contradicts the actual backing type.
This gives downstream code that annotated backed enums this way (e.g. via
shipmonk/phpstan-rules' BackedEnumGenericsRule) a working migration path.

Co-Authored-By: Claude Code
Claude-Session: https://claude.ai/code/session_01QCdFyyKvMdEaU4bywFUist
@JanTvrdik
JanTvrdik force-pushed the generic-backed-enum branch from 427eca1 to 4138545 Compare August 26, 2026 10:03
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.

2 participants