Skip to content

PHPStan Divergences #67

Description

@klunejko

Hello,

I just ran TypePHP on our code base and ran into some issues with template tags/generics. For the first example, the two alternatives that TypePHP accepts (EntityIdInterface or a bare EntityIdInterface), PHPStan rejects with missingType.generics.

Other than that, great job, already surfaced some stale phpdocs and actual incorrect/incomplete array shapes.

Thanks

P.S. Write-up is from claude, however I did confirm/verify every single issue.


1. The * wildcard is treated as mixed and rejected against a bounded template

PHPStan reads Foo<*> as "any type satisfying the template bound" and accepts it. TypePHP resolves * to mixed and then fails its own upper-bound check:

/**
 * @template TEntity of EntityInterface
 */
interface EntityIdInterface {}

final class Probe
{
    /**
     * @param EntityIdInterface<*> $id
     */
    public function wildcard(EntityIdInterface $id): string
    {
        return $id->getValue();
    }
}

$probe->wildcard(OrderEntityId::from('order-1'));

Actual:

TypePHP\Exception\TypeError: Probe::wildcard(): Argument $idGeneric type argument mixed
does not satisfy upper bound Core\EntityInterface of template TEntity
in Core\ValueObject\EntityIdInterface

Expected: accepted, as PHPStan does. Note the missing space in $idGeneric in the message.

Why this matters: * is the only spelling PHPStan accepts for self-referential generics. EntityIdInterface is bounded by EntityInterface, whose second template is EntityIdInterface again, so every spelled-out bound produces missingType.generics (verified with PHPStan on the same probe). The same probe measured the other spellings against an OrderEntityId instance, which binds TEntity = Order through its @extends:

Parameter docblock TypePHP 0.9.1
EntityIdInterface (bare) accepted
EntityIdInterface<*> rejected — * read as mixed
EntityIdInterface<mixed> rejected
EntityIdInterface<EntityInterface> rejected — expects EntityIdInterface<invariant EntityInterface>, but EntityIdInterface<Order> was given
EntityIdInterface<covariant EntityInterface> accepted
EntityIdInterface<covariant EntityInterface<mixed,EntityIdInterface<*>>> accepted (the nested * is not bound-checked)

Suggested fix: in TemplateManager::bindSingleTemplateArgument() (src/Internal/Generics/TemplateManager.php:685-693), substitute the template's bound (covariantly) when the argument node is *.

2. self inside @implements Interface<self> is bound literally

PHPStan resolves self in class-level generic tags to the declaring class. TypePHP keeps the literal self, so a later covariant check against the instance fails:

/**
 * @template TRouteTypeEnum of RouteInterface
 */
interface RouteInterface {}

/**
 * @implements RouteInterface<self>
 */
enum OrderRoute: string implements RouteInterface { /* … */ }

final class Probe
{
    /**
     * @param RouteInterface<covariant RouteInterface<*>> $route
     */
    public function route(RouteInterface $route): string
    {
        return $route->getName();
    }
}

$probe->route(OrderRoute::OrderList);

Actual:

Probe::route(): Argument $route expects Company\Contracts\Routing\RouteInterface<covariant
Company\Contracts\Routing\RouteInterface<*>>, but Company\Contracts\Routing\RouteInterface<self> was given

Changing the enum to @implements RouteInterface<ProbeRoute> makes the same call pass. Expected: self resolves to the enum, as in PHPStan.

3. use … as Alias imports are not resolved inside array shapes

Observed on shared-src/Core/DTO/Traits/HasShippingAddressRelationship.php:9-14:

use Core\Enums\Type as EntityType;

/** @var ?array{data:array{id:string,type:EntityType}} */
protected ?array $shippingAddress = null;

Actual, when the property is assigned a Type enum case:

Property Core\DTO\OrderData::$shippingAddress['data']['type'] must be of type EntityType,
Core\Enums\Type given

The alias is printed unresolved. The declaration sits in a trait consumed by OrderData from another file, and a standalone class with use Core\Enums\Type as TypeAlias; and @param array{type:TypeAlias} $data passes when the alias is declared in the same file as the method. The defect is therefore item 4 below: the trait's import map is not applied when its docblock is resolved in the consuming class.

4. A trait docblock referencing a same-namespace interface without use fails in the consuming class

Observed on shared-src/Company/Contracts/ValueObject/HasValueObjectGuard.php:15-21. The trait lives in Company\Contracts\ValueObject, and its parameter docblock names ValueObjectInterface from that namespace without an import:

namespace Company\Contracts\ValueObject;

trait HasValueObjectGuard
{
    /**
     * @param null|ValueObjectInterface|EntityIdInterface<*> $value
     */
    protected static function has_value(null|ValueObjectInterface|EntityIdInterface $value): bool { /* … */ }
}

Actual, called from Company\Bridge\Query\FilterQueryParser with a FirstName value object that extends AbstractValueObject implements ValueObjectInterface:

Company\Bridge\Query\FilterQueryParser::hasValue(): Argument $value must be of type
(null | ValueObjectInterface | Core\ValueObject\EntityIdInterface<*>), Company\ValueObject\FirstName given

ValueObjectInterface is printed unqualified while the other union member is fully qualified. The native parameter type on the same method resolves correctly, so PHP itself accepts the call:

// Probe/ProbeGuard.php
namespace Probe;

interface ProbeMarker {}

final class ProbeMarkerImpl implements ProbeMarker {}

trait ProbeGuard
{
    /**
     * @param ProbeMarker $marker
     */
    public function guard(ProbeMarker $marker): string
    {
        return $marker::class;
    }
}
// Probe/Sub/ProbeGuardConsumer.php
namespace Probe\Sub;

use Probe\ProbeGuard;

final class ProbeGuardConsumer
{
    use ProbeGuard;
}

(new ProbeGuardConsumer())->guard(new \Probe\ProbeMarkerImpl());

Actual:

TypePHP\Exception\TypeError: Probe\Sub\ProbeGuardConsumer::guard(): Argument $marker must be of type ProbeMarker,
Probe\ProbeMarkerImpl given

Expected: ProbeMarker resolves to Probe\ProbeMarker (the trait's namespace), as PHP and PHPStan do; the call is accepted.

5. A method-level @template T collides with a class-level @template T of the consuming class

Observed on shared-src/responsive8/Reflection/HasReflectionClassHelper.php:116-160 (trait methods declaring @template T of object and @param null|class-string<T>|object $objectOrClass), consumed by responsive8\Vendor\Aws\DynamoDb\DynamoRepository, which declares its own class-level @template T of object. EventLogRepository extends DynamoRepository<CrmEventLog|EmailEventLog|SevdeskEventLog>.

Actual, when the repository constructor calls $this->getSingleClassReflectionAttribute(DiscriminatorColumn::class) (which forwards $this):

Company\Domain\Messaging\Events\Log\EventLogRepository::getCachedReflectionClass(): Argument $objectOrClass
must be of type (class-string<(CrmEventLog | EmailEventLog | SevdeskEventLog)> | (CrmEventLog | EmailEventLog | SevdeskEventLog)),
Company\Domain\Messaging\Events\Log\EventLogRepository given

The method-level T was resolved to the class-level T binding. PHPStan scopes a method template independently of the class template.

6. An inline @var T $object is not bound from a class-string<T> parameter

Observed on shared-src/Company/Integrations/Symfony/Serializer/ConfigArraySerializer.php:124-132. The method declares @template T of object, @param class-string<T> $class, and assigns the denormalized value under /** @var T $object */.

Actual:

Variable $object must be of type T, Tests\Company\Integrations\Symfony\Serializer\SimpleTestEntity given

PHPStan binds T from the $class argument. TypePHP leaves T unresolved for the inline assignment check.

Environment

Component Version
typephp/typephp v0.9.1
phpstan/phpstan 2.2.13, level 7, missingType.generics enabled
PHP 8.5.8, Windows 10
TypePHP config typephp.php from config:init, all checks on, strict_return_generic_invariance: true, respect_native_nullability: true

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions