phpstatic-analysisphpstanpsalm-php

How to tell PHP static code analysers to read the generic type hint from a callable, instead of expecting the class name as a string?


I'm trying to remove some code duplication that has proven to be prone to human errors.

I created a working sample code at https://3v4l.org/QFA6m#v8.2.7 and a demo of PHPStan failing where expected, https://phpstan.org/r/e6310a8c-6691-4096-a698-44eadb1ab1f2

On a high level:

  1. I am using DTO capable of creating an object of another class
    1. interface CrudDto defines public function apply(): CrudEntity
    2. class CreatePersonDto implements CrudDto and its apply() method returns a Person
    3. class Person extends CrudEntity
  2. There is a CrudManager class capable of working with my DTO through CrudManager::save(CrudDto $dto): CrudEntity
  3. Calling $crudManager->save($dto) returns whatever the given DTO can create: PersonCreateDto -> Person, CatCreateDto -> Cat and so on, you get the point.

I'm trying to tell CrudManager that it's not returning a CrudEntity, but a Person, or a Cat, etc.

I achieved so by passing the expected class name to my save function and using things described here https://phpstan.org/blog/generics-by-examples#accept-a-class-string-and-return-the-object-of-that-type

But here comes the core of the question (thanks for reading until this point, btw!)

I would love to insulate the strict type checking so that the developer doesn't have to provide the expected type to the save() method via a class-string<T of CrudEntity> $className.

I want to do that because if the DTO knows what class it is creating, it should be able to give that information to whoever wants it.

I added CrudDto::belongsTo(): string that returns the FQCN of whatever the entity the DTO can produce, and I can use that value to enforce the actual type on runtime, but I didn't find a way to inform static code analysers that the class-string is provided by the DTO itself, not by the argument next to it.

Effectively I'm looking for a pseudocode of something like this:

/**
 * @template T of CrudEntity
 * @param class-string<T> $dto::belongsTo()
 */
public function save(CrudDto $dto): CrudEntity;

In other words, I'd like to tell PHPStan, PHPStorm, psalm and others, that if CreatePersonDto::belongsTo() returns Person::class, calling $entityManager->save($personDto) returns Person, not just CrudDto.

Can it be done without passing the expected return value's class name as a method argument of the save() function?


Solution

  • Yes, it's definitely possible. See https://phpstan.org/r/50d160ac-e66c-4079-a8b6-474d0614cf56

    First, you need to make your CrudDto interface generic:

    /** @template T of CrudEntity */
    interface CrudDto {
      // ...
    }
    

    Then, for every DTO implementation specify /** @extends CrudDto<EntityClass> */:

    /** @implements CrudDto<Person> */
    abstract class PersonDto implements CrudDto {
      // ...
    }
    

    And then you can make the CrudManager::save() method generic:

        /**
         * @template T of CrudEntity
         * @param CrudDto<T> $dto
         * @return T
         */
        public function save(CrudDto $dto): CrudEntity {
           // ...
        }