spatie / typescript-transformer

Transform PHP types to TypeScript
https://docs.spatie.be/typescript-transformer/v2/introduction/
MIT License
274 stars 35 forks source link

Generated enums aren't available as values at runtime #85

Closed felixpackard closed 1 month ago

felixpackard commented 2 months ago

I'm in the process of migrating a codebase to spatie/laravel-data in conjunction with spatie/typescript-transformer so we don't have to manually duplicate PHP types and enums (int-backed) in TypeScript.

One issue that I've run into is since all generated types are encapsulated by declare namespace blocks, enums generated by this package aren't able to be used as runtime values – e.g. declaring a default sorting method within the application state – they can only be used for type checking.

Is there any way this package could, for example, output enums to a regular .ts file in addition to the .d.ts file, or is there another solution to this that I'm missing?

I'm happy to submit a PR for this if there's an easy fix.

Edit 1 – ModuleWriter

I realise the ModuleWriter may be a solution to this, but it would be nice if there was a way to solve this when using the TypeDefinitionWriter as well seeing as it's the default.

Edit 2 – Custom writer

I played around with the idea of a custom writer that calls format on TypeDefinitionWriter, then implements a modified version of the logic in ModuleWriter to append module level exports to the output, but only for types where $type->reflection->isEnum() evaluates to true. Turns out as soon as you include a module level export in the file, the namespaced types are no longer accessible, so the module exports would in fact have to be in a separate file.

shaffe-fr commented 2 months ago

Hi.

I'm tackling the same problem.

I created a custom transform command and two custom writers. The OnlyEnumsModuleWriter writer is responsible to only export enums and the ExceptEnumsModuleWriter writer is responsible to export non-enums types and to import the enums types properly.

I didn't check the $type->reflection->isEnum() but the enum keywork to support non-native enums.

I added a enum_output_file entry to the typescript-transformer config:

    /*
     * The package will write the generated TypeScript to this file.
     */

    'output_file' => resource_path('js/types/generated.d.ts'),

    'enum_output_file' => resource_path('js/enums.generated.ts'),

Here is the composer script to export types:

{
    "scripts": {
        "ts": [
            "@php artisan typescript:custom-transform"
        ],
    }
}

The custom transform command, it should be registered in the console Kernel or in the bootstrap/app.php file:

<?php

namespace App\Domain\Support\TypeScriptTransformer;

use Illuminate\Console\Command;
use Spatie\TypeScriptTransformer\Formatters\PrettierFormatter;
use Spatie\TypeScriptTransformer\Structures\TransformedType;
use Spatie\TypeScriptTransformer\TypeScriptTransformer;
use Spatie\TypeScriptTransformer\TypeScriptTransformerConfig;

class CustomTransformCommand extends Command
{
    protected $signature = 'typescript:custom-transform
                            {--format : Use Prettier to format the output}';

    protected $description = 'Map PHP types to TypeScript, separating enums and non-enums into different files.';

    public function handle(TypeScriptTransformerConfig $config): int
    {
        if ($this->option('format')) {
            $config->formatter(PrettierFormatter::class);
        }

        // Export non-enums first
        $transformer = new TypeScriptTransformer(
            $config
                ->outputFile(config('typescript-transformer.output_file')) // @phpstan-ignore-line
                ->writer(ExceptEnumsModuleWriter::class)
        );
        $transformer->transform();

        // Then export only enums
        $config
            ->outputFile(config('typescript-transformer.enum_output_file')) // @phpstan-ignore-line
            ->writer(OnlyEnumsModuleWriter::class);

        $transformer = new TypeScriptTransformer($config);

        /** @var \Illuminate\Support\Collection<string, \Spatie\TypeScriptTransformer\Structures\TransformedType> $collection */
        $collection = collect($transformer->transform());

        $this->table(
            ['PHP class', 'TypeScript entity'],
            $collection->map(fn (TransformedType $type, string $class) => [
                $class, $type->getTypeScriptName(),
            ])
        );

        $this->info("Transformed {$collection->count()} PHP types to TypeScript");

        return 0;
    }
}

The OnlyEnumsWriter:

<?php

namespace App\Domain\Support\TypeScriptTransformer;

use Spatie\TypeScriptTransformer\Structures\TransformedType;
use Spatie\TypeScriptTransformer\Structures\TypesCollection;
use Spatie\TypeScriptTransformer\Writers\Writer;

class OnlyEnumsModuleWriter implements Writer
{
    public function format(TypesCollection $collection): string
    {
        $output = '';

        /** @var \ArrayIterator $iterator */
        $iterator = $collection->getIterator();

        $iterator->uasort(function (TransformedType $a, TransformedType $b) {
            return strcmp($a->name, $b->name);
        });

        foreach ($iterator as $type) {
            /** @var \Spatie\TypeScriptTransformer\Structures\TransformedType $type */
            if ($type->isInline) {
                continue;
            }

            if ($type->keyword !== 'enum') {
                continue;
            }

            $output .= "export {$type->toString()}".PHP_EOL;
        }

        return $output;
    }

    public function replacesSymbolsWithFullyQualifiedIdentifiers(): bool
    {
        return false;
    }
}

The ExceptEnumsWriter:

<?php

namespace App\Domain\Support\TypeScriptTransformer;

use Exception;
use Spatie\TypeScriptTransformer\Structures\TransformedType;
use Spatie\TypeScriptTransformer\Structures\TypesCollection;
use Spatie\TypeScriptTransformer\Writers\Writer;

class ExceptEnumsModuleWriter implements Writer
{
    public function format(TypesCollection $collection): string
    {
        $output = '';

        $iterator = $collection->getIterator();

        $iterator->uasort(function (TransformedType $a, TransformedType $b) {
            return strcmp($a->name, $b->name);
        });

        $enums = [];

        foreach ($iterator as $type) {
            /** @var \Spatie\TypeScriptTransformer\Structures\TransformedType $type */
            if ($type->isInline) {
                continue;
            }

            if ($type->keyword === 'enum') {
                $enums[] = $type->name;

                continue;
            }

            $output .= "export {$type->toString()}".PHP_EOL;
        }

        if ($enums) {
            $enumsImportOutput = 'import { '.implode(', ', $enums).' } from "'.$this->relativePathToEnumsDefinition().'";'.PHP_EOL.PHP_EOL;
        }

        return ($enumsImportOutput ?? '').$output;
    }

    public function replacesSymbolsWithFullyQualifiedIdentifiers(): bool
    {
        return false;
    }

    private function relativePathToEnumsDefinition(): string
    {
        $from = config('typescript-transformer.output_file');
        $to = config('typescript-transformer.enum_output_file');

        if (! is_string($from) || ! is_string($to)) {
            throw new Exception('Cannot resolve relative path between configurations [typescript-transformer.output_file] and [typescript-transformer.enum_output_file].');
        }

        $from = str_replace('\\', '/', $from);
        $to = str_replace('\\', '/', $to);

        if ($from == $to) {
            throw new Exception('The following path should be different: [typescript-transformer.output_file] and [typescript-transformer.enum_output_file].');
        }

        // Split the paths into arrays
        $fromParts = explode('/', $from);
        $toParts = explode('/', $to);

        // Find the point where the paths diverge
        $i = 0;
        while (isset($fromParts[$i], $toParts[$i]) && $fromParts[$i] === $toParts[$i]) {
            $i++;
        }

        // Go up as many levels as necessary
        $up = array_fill(0, count($fromParts) - $i - 1, '..');

        // Descend into the destination path
        $down = array_slice($toParts, $i);

        // Build the relative path
        $relativePath = implode('/', array_merge($up, $down));

        // If the relative path does not start with "..", prepend "./"
        if (! str_starts_with($relativePath, '..')) {
            $relativePath = "./{$relativePath}";
        }

        return $relativePath;
    }
}
felixpackard commented 2 months ago

@shaffe-fr Thanks for sharing your approach! I ended up going with a similar approach of creating a custom transform command and a custom writer, but I love the attention to detail in your solution, which definitely improves on what I'm using currently, so I'll probably steal parts of it.

I didn't replace the default writer, so the enums are duplicated – which is convenient if I'm just typing data from the backend, but it's easy to forget to import them from the module when using them as a value. I also was lazy and hard-coded values like the output path for the enums, which isn't awful but probably not great practice.

timmaier commented 2 months ago

Also facing this problem thankyou for your solution!

n1c commented 1 month ago

I've also been struggling with this, I use the TypeDefinitionWriter and not the ModuleWriter because I need the namespaces.

My solution was to just make my own new writer that extends the base one and instead of declare namespace here: https://github.com/spatie/typescript-transformer/blob/4e1b7a14ae8ac9a548f776af4f492e4d569e3d38/src/Writers/TypeDefinitionWriter.php#L22 I just do an export namespace instead.

It lets me do this in my typescript for example:

import { App } from '@/types/backend';
import MeasurementType = App.Enums.MeasurementType;

if (measurementType === MeasurementType.HECTARES) {
  // ...
}

Not sure if it's the best thing to do but working for me so far so hopefully it's helpful to others 🤙

rubenvanassche commented 1 month ago

Good solutions, I'm going to close this since there are enough solutions for v2. In v3 a writer will have the following signature:

/** @return array<WriteableFile> */
public function output(
    TransformedCollection $collection,
): array;

Allowing you to split the TransformedCollection between different writers and outputting multiple files at once so then implementing a custom writer should be a lot easier!