Generate typescript types from PHP enums

How to generate typescript enums from PHP enums


We have a laravel project at my workplace, and it got quite annoying having to keep enums in sync between the client and the backend.

So I created a little laravel command to keep ‘em in sync.

Getting all enums!

So the first step was to figure out a way to get the enums in my php app, I decided to do it using composer since it keeps track of the classes already and is actually pretty convenient:

(require base_path('vendor/autoload.php'))->getClassMap(); // => ['Some\Namespace\class' => ..., ...]

Then to get the enums at my App\Enums namespace, get the keys and then filter to matching namespaces. Oh, and also only let BackedEnums through.

Then we get something like this

 /** @var ClassLoader */
$composer = require base_path('vendor/autoload.php');

/** @var Collection<class-string<BackedEnum>> */
$enumClasses = collect($composer->getClassMap())->keys()->filter(function (string $class) use ($enumsNamespace) {
    return str_starts_with($class, 'App\Enums') && is_subclass_of($class, BackedEnum::class);
});

Making a typescript enum from a php enum

So in php we can call static methods on class strings, which we have here, and that is something we will use.

Lets assume we have this enum that contains a statuses for an order or something:

/** @var class-string<BackedEnum> */
$enum = 'App\Enums\Status';

The first step is to get the ts enums name, and that can be the same name as the php enum and we can get that from the classString with the function class_basename

$name = class_basename($enum); // Status

Then we will want to get all the cases and format them so we can write it to a file and have it be correct, for example wrapping string values in quotationmarks.

$cases = collect(array_map(function (BackedEnum $case) {
    return [
        'name' => $case->name,
        // string values must be "rendered" with quotation marks or it will become "raw"
        'value' => is_string($case->value) ? "\"{$case->value}\"" : $case->value,
    ];
}, $enum::cases()));

Now to the exciting part (may be an overstatement), writing the typescript file! Which is reallt just formatting an Heredoc string, which can have syntax highliting and stuff, pretty straight forward.

$fileName = Str::kebab($name);

file_put_contents(
    "{$tsEnumsDir}/{$fileName}.ts",
    // Write to file as close to prettier format we can
    <<<TS
        /**
         * Auto-generated from PHP enum {$enum}
         */
        export enum {$name} {
        {$cases->map(fn ($case) => "    {$case['name']} = {$case['value']}")->implode(",\n")},
        }

        TS
);

Complete command

And thats it, lets combine everything! (And add some laravel specific stuff to make the output a bit nicer)

<?php

namespace App\Console\Commands;

use BackedEnum;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Composer;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Laravel\Prompts\Progress;

use function Laravel\Prompts\progress;

class SyncEnumsToTypescript extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'sync-enums-to-typescript {--namespace=} {--outdir=}';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Sync php enums to typescript enums.';

    /**
     * Execute the console command.
     */
    public function handle()
    {
        $enumsNamespace = $this->option('namespace') ?? 'App\Enums';
        $tsEnumsDir = $this->option('outdir') ?? resource_path('js/enums');

        $this->components->info('dumping autoloads...');

        // We depend on composer dump-autoload to generate the class map
        /** @var Composer */
        app(Composer::class)->dumpAutoloads();

        /** @var ClassLoader */
        $composer = require base_path('vendor/autoload.php');

        /** @var Collection<class-string<BackedEnum>> */
        $enumClasses = collect($composer->getClassMap())->keys()->filter(function (string $class) use ($enumsNamespace) {
            return str_starts_with($class, $enumsNamespace) && is_subclass_of($class, BackedEnum::class);
        });

        $this->newLine();

        progress('Syncing enums to typescript...', $enumClasses, function (string $enum, Progress $progress) use ($tsEnumsDir) {
            /** @var class-string<BackedEnum> $enum */
            $name = class_basename($enum);

            $progress->label("Syncing {$name} enum to typescript...");

            $cases = collect($enum::cases())
                ->map(function (BackedEnum $case) {
                    return [
                        'name' => $case->name,
                        // string values must be "rendered" with quotation marks or it will become "raw"
                        'value' => is_string($case->value) ? "\"{$case->value}\"" : $case->value,
                    ];
                });

            $fileName = Str::kebab($name);

            File::put(
                "{$tsEnumsDir}/{$fileName}.ts",
                // Write to file as close to prettier format we can
                <<<TS
                 /**
                  * Auto-generated from PHP enum {$enum}
                  */
                 export enum {$name} {
                 {$cases->map(fn ($case) => "    {$case['name']} = {$case['value']}")->implode(",\n")},
                 }

                 TS
            );
        });
    }
}

Some stuff in there is laravel specific, like the dump autoload and the usage of laravel prompts, but it it is pretty trivial to convert it to non laravel.