Introducing TypeScript Transformer 3

Three years in the making, TypeScript Transformer 3 is finally here!

Two years ago on a Friday I started working on a crazy plan: take one of my most used PHP packages, nuke every line of code and start rewriting it from scratch.

What initially felt like something that could be done in weeks ended up taking a lot more time than I anticipated. In the time it took to release TypeScript Transformer 3, my wife got pregnant, my daughter turned one and the whole AI boom happened, whoops.

In this blog post we go through what's new and why we did a rewrite!

Rewriting the package from scratch

It's not every day that you decide to rewrite something from scratch (if you're not an eager LLM coding agent at least). In the case of TypeScript Transformer the choice was fairly easy.

While the previous version of TypeScript Transformer certainly worked, it was really difficult to extend it. The best example of this is probably the most common use case: combining the package with Laravel Data.

Instead of using the built-in support to transform classes to TypeScript and sprinkling some extra stuff on it for use with Data objects, the Laravel Data package defines its own transform infrastructure, merely seeing TypeScript Transformer as a system to find Data classes and provide raw TypeScript to it. This meant that using certain features TypeScript Transformer provided like hiding properties, defining literal TypeScript types and more didn't work.

In essence v2 of TypeScript Transformer had the following pipeline:

  • Find classes satisfying a condition
  • Transform those classes to a textual representation of TypeScript
  • If a class references another one, replace that PHP reference with a TypeScript reference
  • Output everything to a file

In v3 we wanted to rework the whole idea and see TypeScript Transformer more like a framework to construct a bridge between PHP and TypeScript.

What's new

Ditching Collectors

In the previous version the flow was cumbersome: you had a Collector that would search for classes within your codebase satisfying a certain condition. Collectors could search for specific Data classes, enums or classes tagged with a #[TypeScript] attribute.

All classes found by the collector would then be fed to a transformer that would convert the class into TypeScript.

In v3 we've ditched the Collector concept. We take every class within your application, pass it through all transformers and the first transformer to return a valid Transformed object wins.

By eliminating the Collector concept we try to encourage developers to create their own transformer to suit their own needs.

By default, the package provides an abstract ClassTransformer which can be configured and an AttributedClassTransformer which will transform all classes with a #[TypeScript] attribute.

More than classes

Previously we focused on transforming classes into TypeScript types. In this version that's only a small part of the package. A new concept is introduced called a TransformedProvider, which allows you to define a Transformed object, put simply: a piece of TypeScript code. It can be a type, an object, a function or any piece of runnable TypeScript code. It has a name, a location where it should be put (either a TypeScript namespace or module file) and can be referenced by other Transformed objects.

TransformedProviders will create these Transformed objects, the class transformation flow where classes are passed through transformers is an example of a TransformedProvider but there are many possibilities. For example, a TransformedProvider could generate a Laravel route helper function that creates route URLs in TypeScript like it would in PHP. Another could add additional types like paginated collections to your TypeScript codebase, or share permission definitions and configuration values with the frontend.

PHPStan in the core

The first version of TypeScript Transformer was created five years ago. It could only transform enums at that time. Over the past five years we've seen massive improvements to PHP's type system and annotating types has become the default. That's all thanks to tools like PHPStan providing crucial insights into the inner workings of your code.

Version 2 of the package already had support for annotations through the PHPDocumentor package. While PHPDocumentor has been updated for new constructs like array shapes, it is missing the detail and insights the PHPStan core provides to parse these annotations.

That's why we've swapped PHPDocumentor for PHPStan's PHPDoc parser, allowing you to type things like:

Array Shapes

class CreateUserData {
    /** @var array{name: string, email: string, age?: int} */
    public array $payload;
}
PHP
{ name: string; email: string; age?: number }
TypeScript

Object shapes

class Config {
    /** @var object{host: string, port: int, secure?: bool} */
    public object $database;
}
PHP
{ host: string; port: number; secure?: boolean }
TypeScript

Generic types

use Illuminate\Support\Collection;

class Team {
    /** @var Collection<int, User> */
    public Collection $members;
}
PHP
Collection<number, User>
TypeScript

Enum values & keys

enum Status: string {
    case Active = 'active';
    case Inactive = 'inactive';
    case Pending = 'pending';
}

class Filter {
    /** @var value-of<Status> */
    public string $status;

    /** @var key-of<Status> */
    public string $statusKey;
}
PHP
// value-of<Status>
'active' | 'inactive' | 'pending'

// key-of<Status>
'Active' | 'Inactive' | 'Pending'
TypeScript

Const types

class Endpoints {
    public const DELETE = 'delete';

    /** @var array{self::DELETE: string} */
    public array $endpoints;
}
PHP
{ delete: string }
TypeScript

A proper AST

In previous versions a transformer would return a plain string of TypeScript code. While this certainly did the job, quickly creating new transformers or adapting the output of a transformer was nearly impossible.

In the newest version of the package, a transformed object always contains a node representing the AST of that symbol. You can still use the TypeScriptRaw node to return the full code as a single string, but the package provides more than 40 built-in nodes representing a subset of the TypeScript language.

One of the most useful nodes is the TypeScriptReference node. It holds a reference to another transformed object and will be resolved when the nodes are written into a textual TypeScript representation.

In previous versions we would do this by string replacing %ReferenceName% snippets in the already written TypeScript code. A dedicated node representing links between symbols is a huge win!

Revamped writers

TypeScript Transformer comes with two writers: a namespace writer that outputs all Transformed symbols based upon their PHP namespace into one TypeScript file mimicking those namespaces, and a module writer splitting namespaces into files and putting the Transformed symbols in there.

In this version of the package we allow individual transformers to store Transformed objects into multiple writers, allowing you to store all your types in a namespace file while keeping helper code like the Laravel route helper in a module.

We even made it possible to reference symbols using the TypeScriptReference nodes across writers. So you can reference a module exported symbol in a namespace written file and vice versa.

Watching your code evolve

One of the coolest new features in TypeScript Transformer 3 has to be the watch mode. You can start this mode in Laravel by running php artisan typescript:transform --watch. The framework-agnostic package allows you to configure this when starting the package.

The watch mode uses a supervisor/worker architecture, initially a supervisor is started which then spawns a worker. The worker starts a listener for file updates in the paths TypeScript Transformer is watching.

Once something changes we actually cannot rely on PHP's reflection API to get the updates to the file. To solve this we'll read the file using the Roave Reflection package and parse it into a reflection object which can be used throughout the package to build up Transformed objects.

That's why TypeScript Transformer doesn't provide reflection objects to transformers to build TypeScript from. We've created our own abstraction on top of PHP's built-in reflection classes and Roave's reflection classes called PhpNode. Examples of such nodes are PhpClassNode, PhpPropertyNode, PhpMethodNode and more. By using the PhpNode classes a developer never has to remember with which kind of reflection they're working. It will just work.

Why the supervisor/worker architecture? If for some reason TypeScript Transformer crashes, the worker will automatically restart itself thanks to the supervisor. It will even smartly retry a few times before giving up.

Another reason we've chosen this architecture is that a worker will always live inside your preferred framework. Let's say you're generating TypeScript for the routes within your application. In Laravel we can call php artisan route:list --json and get all the routes in a JSON form. Now what happens if someone updates a route and we didn't have this architecture? Since we're still running the same process after the change, no updates will be taken into account when running the route:list command again. That's why TransformedProviders have an escape hatch when watching directories, they can send an escape signal, telling the worker to restart itself and thus have the updated routes available when running route:list again.

In the case of the code for generating Laravel routes we actually use another approach for refreshing the routes since restarting workers every time when something route-based changes can be quite performance heavy. We're sure there are many more use cases like this to discover and optimize.

Watch mode is in beta for now, and we expect a lot of bugs but it will be fun fixing them and optimizing the mode to make development even faster!

Laravel routes helper

TypeScript Transformer can communicate with Laravel and fetch all defined routes allowing us to generate a route helper that can be used in TypeScript:

import {route} from './helpers/route';

// Without parameters
const indexUrl = route('users.index');
// https://laravel.dev/users

// With parameters
const userUrl = route('users.show', {user: 1});
// https://laravel.dev/users/1

You can configure the helper to exclude specific routes from being exposed to the frontend.

Laravel controllers helper

Probably one of my favorite features we've built is a complete mapping from controller to TypeScript. We initially showed this 3 years ago at Laracon US with a copy of the action helper we've got in Laravel but then usable in TypeScript:

action(['UserController', 'update'], {id: 1});

Unfortunately we didn't find a way to get this working the way we wanted due to limitations with inferring generic arrays in TypeScript.

In the meantime a new Laravel package was released providing an elegant API which we loved and we extended it with extra functionality.

In previous versions of TypeScript Transformer, the only way to communicate endpoints from the backend to frontend was by hardcoding them in data objects which could then be sent alongside the data to the frontend. It kinda felt stupid to build these objects and they were adding a lot of extra code and data over the wire.

From now on, you can import a Laravel controller into TypeScript as such:

import { PostsController } from './controllers';

Generating a URL and getting the HTTP method is now as simple as:

const { url, method } = PostsController.index();

Does your endpoint require a parameter? It can be passed like this:

const { url, method } = PostsController.show({ post: 1 });

Since everything is typed, when a route signature changes, e.g. an extra parameter is added, the TypeScript compiler will complain making sure your code is always correct!

We didn't stop there. While it is not common to add return types in Laravel, we'd recommend you start doing this from now on.

Let's say we type a controller response as an array:

class DashboardController
{
    /** @return array{totalUsers: int, activeUsers: int, revenue: float} */
    public function stats(): array
    {
        return [
            'totalUsers' => User::count(),
            'activeUsers' => User::where('active', true)->count(),
            'revenue' => Order::sum('total'),
        ];
    }
}

You can now get the response type of this controller in your TypeScript code as such:

DashboardController.stats.Response // { totalUsers: number; activeUsers: number; revenue: number }

Using Laravel Data, typing responses is even simpler:

class PostsController
{
    public function show(string $post): PostData
    {
        return PostData::from(Post::findOrFail($post));
    }
}

In TypeScript you can find the response as PostsController.show.Response which of course will be PostData.

Using Inertia? We've got you covered. While the Inertia response type officially doesn't support generics (yet?) we can type it as such:

class ProjectsController
{
    /** @return \Inertia\Response<ProjectData> */
    public function show(string $project): \Inertia\Response
    {
        return inertia('Projects/Show', ProjectData::from(
            Project::findOrFail($project)
        ));
    }
}

Of course TypeScript Transformer is smart enough to know that ProjectsController.show.Response is of ProjectData type.

If you're using Laravel Data to inject data objects built from the request then you're in for a treat! Let's say your controller looks like this:

class PostsController
{
    public function store(StorePostData $data): PostData
    {
        return PostData::from(Post::create($data->all()));
    }
}

TypeScript Transformer is smart enough to know the request type of this controller endpoint is StorePostData, allowing us to write this beautiful piece of code:

const post: PostsController.store.Request = {
    title: 'Hello world',
    body: 'Lorem ipsum...',
};

const { url, method } = PostsController.store();

const response = await fetch(url, {
    method,
    body: JSON.stringify(post),
});

We don't have support for Laravel requests or Eloquent resources (for now). Laravel Ranger is a new official package from Laravel which should be able to type these. We're currently looking at whether their output can be mapped onto our type system, which would make it possible to transform these as well. Stay tuned later this year!

Smaller changes

  • We now have a visitor system allowing developers to visit the TypeScript nodes generated, change, add or remove them.
  • The TypeScriptLiteral attribute now allows importing other TypeScript symbols from your codebase, even ones not generated by the package
  • We removed the functionality to inline TypeScript types instead of referencing them

Wayfinder

While we were building version three of the package, Laravel decided to release their own framework to transform types and routes to the frontend. I think they were quite inspired by our work but took a different direction.

Since the beginning, Laravel requests and resources aren't typed. A request is a set of validation rules while a resource is basically just an array which gets returned. In the first versions we actually tried to make this work, by parsing PHP's AST and trying to construct the types for the resources and requests.

It failed miserably. While it works for simple cases, as soon as we created more complicated resources or requests the parser got completely confused and we threw that code away.

Instead we built Laravel Data. Typed requests and resources allowed us to be strict in the backend while providing exact type definitions which could be used in the frontend. A match made in heaven!

Another disadvantage of Wayfinder's approach is that parsing PHP ASTs is slow ... really slow. In our watch mode we actually also have to do this in order to construct the Roave reflection objects. We optimized that part of our flow so well that we'll never try to parse an AST which shouldn't be parsed. On the other hand, TypeScript Transformer relies heavily on PHP's built-in reflection which is blazingly fast.

While we and Wayfinder try to solve the same problem, I'm kinda glad with the approach we took. In the future we still hope to use their parsed Laravel request and Eloquent resources types but for now we keep doing our own thing!

TypeScript Transformer in an agentic LLM era

Over the past few years our whole world has been shaken up with the introduction of agents writing code for us. Is there still a place for a package like TypeScript Transformer?

I can quickly answer that with a loud yes! There are a few reasons:

  • TypeScript Transformer bases itself on facts, there are no hallucinations, no context missing. The package has a knowledge graph of all code which should be shared between frontend and backend and only does that based upon the types provided.
  • The watch mode makes this even easier, you can start it, let your agents refactor PHP code and automatically the TypeScript will be updated
  • You won't lose a single token on writing (or probably most of the time rewriting) TypeScript definitions which is nice and allows your LLM to do the work which is really important

Upgrading

There's no upgrade guide from v2 since it is a completely new package and there was no way to write one.

We recommend you read through the docs and just try transforming types and see what breaks.

Closing

I had a blast rebuilding this package. It has become exactly what I wanted: a new way to build bridges between frontend and backend.

Since this is now released I'm going to start working on laravel-data v5 which should be easier to use and a lot faster, see you next time!