Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
felixpackard opened this issue Aug 13, 2024 · 5 comments
Closed

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

felixpackard opened this issue Aug 13, 2024 · 5 comments

Comments

@felixpackard
Copy link

felixpackard commented Aug 13, 2024

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
Copy link

shaffe-fr commented Aug 21, 2024

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
Copy link
Author

@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
Copy link

Also facing this problem thankyou for your solution!

@n1c
Copy link

n1c commented Sep 19, 2024

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:

$output .= "declare namespace {$namespace} {".PHP_EOL;
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
Copy link
Member

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!

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

No branches or pull requests

5 participants