Type-safe enum metadata with Hybridly and Laravel

When I work on the front-end of a Laravel and Hybridly application, I always aim for as much type safety as possible. Here's how to add type-safe metadata to enums.

written on

When a Hybridly application is scaffolded, spatie/typescript-transformer is included. This package is what powers the automatic TypeScript type generation from PHP data objects and enums.

By default, it converts enums to a simple union of their values:

types.d.ts
export type Permission =
    | 'notices-to-airmen:view'
    | 'notices-to-airmen:acknowledge'
    | 'notices-to-airmen:override'

However, I often need metadata with my enums. Things like labels, descriptions, colors, icons... stuff that makes the enum useful in a UI context.

Implementing metadata transformation

Well, it's actually pretty straightforward to implement this with a custom transformer, which the transformer package has support for:

src/Infrastructure/HybridableEnumTransformer.php
use Hybridly\Support\Hybridable;
use ReflectionEnumBackedCase;
use Spatie\TypeScriptTransformer\Transformers\EnumTransformer;

final class HybridableEnumTransformer extends EnumTransformer
{
    protected function toEnumValue(ReflectionEnumBackedCase $case): string
    {
        $enum = $case->getValue();

        if ($enum instanceof Hybridable) {
            return json_encode($this->transform($enum->toHybridArray()));
        }

        return parent::toEnumValue($case);
    }

    private function transform(mixed $value): mixed
    {
        if ($value instanceof Hybridable) {
            return $this->transform($value->toHybridArray());
        }

        if (is_array($value)) {
            return array_map(fn (mixed $item) => $this->transform($item), $value);
        }

        return $value;
    }
}

This transformer checks if the enum being transformed to a TypeScript type implements Hybridable. If it does, it recursively handles nested Hybridable properties returned by toHybridArray() and JSON-encodes the result, which would actually produce a valid TypeScript interface.

Because we're not using Tempest, this transformer class has to be manually registered in the package's config:

config/typescript-transformer.php
// ...

'transformers' => [
    \Infrastructure\Hybridable\HybridableEnumTransformer::class,

    // ...
],

// ...

With that in place, to add metadata to an enum, simply implement the Hybridable interface and define the toHybridArray() method:

src/Security/Authorization/Permission.php
use Hybridly\Support\Hybridable;

enum Permission: string
{
    case VIEW_NOTICES_TO_AIRMEN = 'notices-to-airmen:view';
    case ACKNOWLEDGE_NOTICES_TO_AIRMEN = 'notices-to-airmen:acknowledge';
    case OVERRIDE_NOTICES_TO_AIRMEN = 'notices-to-airmen:override';

    public function toHybridArray(): array
    {
        return match ($this) {
            self::VIEW_NOTICES_TO_AIRMEN => [
                'value' => $this->value,
                'label' => 'Allows accessing the NOTAM acknowledgement tool.',
            ],
            self::ACKNOWLEDGE_NOTICES_TO_AIRMEN => [
                'value' => $this->value,
                'label' => 'Allows acknowledging a notice.',
            ],
            self::OVERRIDE_NOTICES_TO_AIRMEN => [
                'value' => $this->value,
                'label' => 'Allows overriding a notice.',
            ],
        };
    }
}

This is it! When passing this enum to the front-end, it will now have type-safe metadata.

Abstracting metadata into attributes

In a project where you use a lot of enums, implementing toHybridArray() on each enum can get repetitive.

To solve this, I created a couple of attributes and traits that use Reflection to find them and build the array.

You may find the implementation here. The experience looks like this:

src/Security/Authorization/Permission.php
enum Permission: string implements Hybridable
{
    use HasMetadata;
    use IsHybridableEnum;

    #[Label('View motices to airmen')]
    #[Description('Allows accessing the NOTAM acknowledgement tool.')]
    case VIEW_NOTICES_TO_AIRMEN = 'notices-to-airmen:view';
}