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 |
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 asmixedand rejected against a bounded templatePHPStan reads
Foo<*>as "any type satisfying the template bound" and accepts it. TypePHP resolves*tomixedand then fails its own upper-bound check:Actual:
Expected: accepted, as PHPStan does. Note the missing space in
$idGenericin the message.Why this matters:
*is the only spelling PHPStan accepts for self-referential generics.EntityIdInterfaceis bounded byEntityInterface, whose second template isEntityIdInterfaceagain, so every spelled-out bound producesmissingType.generics(verified with PHPStan on the same probe). The same probe measured the other spellings against anOrderEntityIdinstance, which bindsTEntity = Orderthrough its@extends:EntityIdInterface(bare)EntityIdInterface<*>*read asmixedEntityIdInterface<mixed>EntityIdInterface<EntityInterface>expects EntityIdInterface<invariant EntityInterface>, but EntityIdInterface<Order> was givenEntityIdInterface<covariant EntityInterface>EntityIdInterface<covariant EntityInterface<mixed,EntityIdInterface<*>>>*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.
selfinside@implements Interface<self>is bound literallyPHPStan resolves
selfin class-level generic tags to the declaring class. TypePHP keeps the literalself, so a later covariant check against the instance fails:Actual:
Changing the enum to
@implements RouteInterface<ProbeRoute>makes the same call pass. Expected:selfresolves to the enum, as in PHPStan.3.
use … as Aliasimports are not resolved inside array shapesObserved on
shared-src/Core/DTO/Traits/HasShippingAddressRelationship.php:9-14:Actual, when the property is assigned a
Typeenum case:The alias is printed unresolved. The declaration sits in a trait consumed by
OrderDatafrom another file, and a standalone class withuse Core\Enums\Type as TypeAlias;and@param array{type:TypeAlias} $datapasses 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
usefails in the consuming classObserved on
shared-src/Company/Contracts/ValueObject/HasValueObjectGuard.php:15-21. The trait lives inCompany\Contracts\ValueObject, and its parameter docblock namesValueObjectInterfacefrom that namespace without an import:Actual, called from
Company\Bridge\Query\FilterQueryParserwith aFirstNamevalue object that extendsAbstractValueObject implements ValueObjectInterface:ValueObjectInterfaceis 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:Actual:
Expected:
ProbeMarkerresolves toProbe\ProbeMarker(the trait's namespace), as PHP and PHPStan do; the call is accepted.5. A method-level
@template Tcollides with a class-level@template Tof the consuming classObserved on
shared-src/responsive8/Reflection/HasReflectionClassHelper.php:116-160(trait methods declaring@template T of objectand@param null|class-string<T>|object $objectOrClass), consumed byresponsive8\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):The method-level
Twas resolved to the class-levelTbinding. PHPStan scopes a method template independently of the class template.6. An inline
@var T $objectis not bound from aclass-string<T>parameterObserved 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:
PHPStan binds
Tfrom the$classargument. TypePHP leavesTunresolved for the inline assignment check.Environment
missingType.genericsenabledtypephp.phpfromconfig:init, all checks on,strict_return_generic_invariance: true,respect_native_nullability: true