Generic BackedEnum

Hacking generics into BackedEnum in PHP 8.1

Finally, PHP got proper native enums. We can back them with strings or ints:

enum MyStringEnum: string
{
    case MyCase = 'mycase';
}

enum MyIntEnum: int
{
    case MyCase = 100;
}

Internally, any enum with values implements BackedEnum interface. But there is no interface to distinguish string ones from integer ones. PHPStorm is using IntBackedEnum and StringBackedEnum internally, but that is not usable in your codebase. This becomes problematic in functions similar to this one:

/**
 * @param BackedEnum[] $enums
 * @return int[]|string[]
 */
public static function enumsToValues(array $enums): array
{
    return array_map(fn(BackedEnum $enum) => $enum->value, $enums);
}

The return value int[]|string[] is not precise. We know it depends on the input. How to solve that? In PHPStan, we could write custom DynamicMethodReturnTypeExtension for this method, use ClassReflection::getBackedEnumType and teach PHPStan when it returns int[] and when string[]. But there is a simpler way. Let’s add generics to BackedEnum at least for PHPStan! We can define BackedEnum.php.stub exactly as it is in PHP, but add the generic template:

/** @template T of int|string */
interface BackedEnum
{
    /** @var T */
    public $value;
}

Register it in phpstan.neon.dist:

stubFiles:
    - ./stubs/BackedEnum.php.stub

And use it:

/**
 * @implements BackedEnum<string>
 */
enum MyStringEnum: string
{
    case MyCase = 'mycase';
}

Now PHPStan starts complaining about using @implements without any interface used, let’s ignore that in phpstan.neon.dist:

ignoreErrors:
	- '#^Enum .*? has @implements tag, but does not implement any interface.$#'

Great, now we can improve our function to use generics and return proper value:

/**
 * @param BackedEnum<T>[] $enums
 * @return list<T>
 *
 * @template T of string|int
 */
public static function enumsToValues(array $enums): array
{
	return array_map(fn(BackedEnum $enum) => $enum->value, $enums);
}

Ok, but this way, PHPStan is not checking that every child of generic BackedEnum needs to define the type of generic value. Meaning I can still define enum without defining its BackedEnum generic type like this:

enum MyStringEnum: string
{
    case MyCase = 'mycase';
}

So we need to ensure that nobody forgets about the @implements tag. For that purpose, there is a ShipMonk’s custom PHPStan rule.

About author:

Jan Nedbal

Architect & developer at ShipMonk



Leave a Reply