Smart generics in PHP

Posted in PHP, Development, 1 year ago Reading time: 5 minutes
image

Some things start really small but have a tendency to turn in a huge project. I had a simple idea for a PHP Mastodon app. Unfortunately the existing Mastodon API implementations for PHP were incomplete, outdated, or disappointing in other ways. So I decided to build my own.

Building an API client can be really simple and basic, but that was not what I wanted. I wanted to build one that would be able to offer the optimal developer's experience. Ideally, it should be that good that you wouldn't need any external documentation, because everything's documented in code. And of course it is very nice if your IDE is able to understand the code well, telling you the return types, needed arguments and properties of every call.

Type hinting in PHP has come a long way, compared to not so long ago. But there are still considerable limitations. For example, take the following example:

class GetAccountRequest implements RequestInterface
{
    //
}

class GetListsRequest implements RequestInterface
{
    //
}

class ApiClient
{
    public function send(RequestInterface $request): ResultInterface
    {
        // ...
        return (new ResultFactory())->build( /* ... */ );
    }
}

// instance of AccountResult, but your IDE does not know
$accountResult = $client->send(new GetAccountRequest('123'));

// instance of ListResult, but your IDE does not know
$listsResult = $client->send(new GetListsRequest());

Your IDE understands that $accountResult and $listsResult are implementations of ResultInterface, but it knows nothing about the specific clsases that exit the factory in practice. That is run time information, and to your IDE, that's a mystery.

Are there any ways to tell your IDE more about the specific return types?

One option would be to create a separate version for each different signature, which you can type hint. Maybe in a "hidden" stub file that is just there for your IDE. But anyy way, that would mean a lot of code duplication. You don't want that.

This approach is quite common. For example, to make your IDE (and you) understand the "Laravel magic" like Facades, Macro's and dynamic properties, it is recommended to use the laravel-ide-helper package.

But I don't really use magic, I don't like it. So there must be a better way without using stubs.

Well, there is.

Generics might be PHP's most discussed feature that doesn't exist. It would make many things easier and better, for example, if you could specify that an array consists of only elements of type X. It would be even cooler if this was enforced by the language. Most compiled languages, from C# to Rust to Typescript have some sort of enforced generics built in. But not PHP. PHP is not compiled, it is interpreted, and runtime checking of Generics types would mean too much of a performance hit. For this reason, full generics will probably never be a thing for PHP.

But it may not be supported in the language itself, there are other ways to "fake" some support. Many years ago, PHPStorm started supporting the first generics like things in docblocks. So in comment blocks, that are ignored (well, mostly) by the PHP interpreter, and that was solely meant to give the IDE some handy meta information about the code. Not a bad idea, and others followed suit. Static code analysers like PHPStan and Psalm started supporting it in some form, and other Language Server implementations like intelephense followed. In the end, most parties managed to agree on some sort of unofficial standard.

Without generics, every element in an array will be of the mixed type, which doesn't tell you anything useful. Add some generics and things change.

/**
 * @return array<array-key, ModelInterface>
 */
function getModels(): array
{
    // ...
    
    return $models;
}

foreach (getModels() as $model) {
    // your IDE knows that $model is a ModelInterface implementation
}

With generics it is also possible to create a relation between two different classes or interfaces, and you can reference this relation in other places. For example, you can relate specific Request classes with specific Response classes, and we are able to solve the issue from the first example:

/**
 * @template TDerivative of ResponseInterface
 */
interface RequestInterface
{
}

interface ResponseInterface
{
}

/**
 * @implements RequestInterface<FooResponse>
 */
class FooRequest implements RequestInterface
{
}

/**
 * @implements RequestInterface<BarResponse>
 */
class BarRequest implements RequestInterface
{
}

class Client
{
    /**
     * @template T of ResponseInterface
     * @param RequestInterface<T> $request
     * @return T
     */
    public function send(RequestInterface $request): ResponseInterface
    {
        // ...
        return (new ResultFactory())->build( /* ... */ );
    }
}

$client = new Client;

// your IDE knows $response is a FooResponse
$response = $client->send(new FooRequest);

// your IDE knows $response is a BarResponse
$response = $client->send(new BarRequest);

The RequestInterface is made generic by defining the @template tag. This template captures implementations of ResponseInterface. In implementations of this RequestInterface, the template is implemented as well, using the @implements tag. In the Client::send() method above, this template is referenced to make the return type of the function known. As a result, your IDE is able to infer the return type based on the type of argument passed to it!

I use this approach in my Mastodon API client, and it works great. It gives a much better developer's experience because you don't have to grep through the source tree to find the correct class, or use your debugger to inspect the type of variables, because your IDE will tell you in advance, and provide you with all relevant suggestions.

Another advantage is that it helps static analysers like PHPStan to better understand your code and also perform deeper checks, to catch more possible bugs in an early stage.

Generics have many more possibilities. A good starting point is the PHPStan documentation and examples.

  • https://phpstan.org/blog/generics-in-php-using-phpdocs
  • https://phpstan.org/blog/generics-by-examples
  • https://stitcher.io/blog/generics-in-php-1
  • https://github.com/vazaha-nl/mastodon-api-client

Related posts

image
Nextcloud

Nextcloud is sometimes called a "google killer". This versatile open source app can be a replacement for many google (and other) cloud services, giving you back control over your data.

Read more →

image
A new blog platform

It was about time I started writing more about my profession, after all those years. So I started a blog. I built the platform myself. So I wrote a blog post about that.

Read more →

image
Security tips and best practices for web developers

Security is hard. I compiled a list with tips and best practices that may be useful.

Read more →

image
Thirty years of Debian!

Today, August 16 2023, marks the 30th anniversary of the Debian GNU/Linux distribution. It was the first linux version I installed after ditching Windows completely. And today, Debian is still very relevant.

Read more →