PHPStan lets you override how it converts PHPDoc Type AST coming from its phpdoc-parser library into its type system representation. This can be used to introduce custom utility types - a popular feature known from other languages like TypeScript.
The implementation is all about applying the core concepts like the type system so check out that guide first and then continue here.
The conversion is done by a class called TypeNodeResolver. That’s why the interface to implement in this extension type is called TypeNodeResolverExtension:
namespace PHPStan\PhpDoc;
use PHPStan\Analyser\NameScope;
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
use PHPStan\Type\Type;
interface TypeNodeResolverExtension
{
public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type;
}
The implementation needs to be registered in your configuration file:
services:
-
class: MyApp\PHPStan\MyTypeNodeResolverExtension
tags:
- phpstan.phpDoc.typeNodeResolverExtension
TypeNodeResolverExtension cannot have TypeNodeResolver injected in the constructor due to circular reference issue, but the extensions can implement TypeNodeResolverAwareExtension interface to obtain TypeNodeResolver via a setter.
An example #
Let’s say we want to implement the Pick utility type from TypeScript. It will allow us to achieve the code in the following example:
/**
* @phpstan-type Address array{name: string, surname: string, street: string, city: string, country: Country}
*/
class Foo
{
/**
* @param Pick<Address, 'name' | 'surname'> $personalDetails
*/
public function doFoo(array $personalDetails): void
{
\PHPStan\dumpType($personalDetails); // array{name: string, surname: string}
}
}
This is how we’d be able to achieve that in our own TypeNodeResolverExtension:
namespace MyApp\PHPStan;
use PHPStan\Analyser\NameScope;
use PHPStan\PhpDoc\TypeNodeResolver;
use PHPStan\PhpDoc\TypeNodeResolverAwareExtension;
use PHPStan\PhpDoc\TypeNodeResolverExtension;
use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode;
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
class MyTypeNodeResolverExtension implements TypeNodeResolverExtension, TypeNodeResolverAwareExtension
{
private TypeNodeResolver $typeNodeResolver;
public function setTypeNodeResolver(TypeNodeResolver $typeNodeResolver): void
{
$this->typeNodeResolver = $typeNodeResolver;
}
public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type
{
if (!$typeNode instanceof GenericTypeNode) {
// returning null means this extension is not interested in this node
return null;
}
$typeName = $typeNode->type;
if ($typeName->name !== 'Pick') {
return null;
}
$arguments = $typeNode->genericTypes;
if (count($arguments) !== 2) {
return null;
}
$arrayType = $this->typeNodeResolver->resolve($arguments[0], $nameScope);
$keysType = $this->typeNodeResolver->resolve($arguments[1], $nameScope);
$constantArrays = $arrayType->getConstantArrays();
if (count($constantArrays) === 0) {
return null;
}
$newTypes = [];
foreach ($constantArrays as $constantArray) {
$newTypeBuilder = ConstantArrayTypeBuilder::createEmpty();
foreach ($constantArray->getKeyTypes() as $i => $keyType) {
if (!$keysType->isSuperTypeOf($keyType)->yes()) {
// eliminate keys that aren't in the Pick type
continue;
}
$valueType = $constantArray->getValueTypes()[$i];
$newTypeBuilder->setOffsetValueType(
$keyType,
$valueType,
$constantArray->isOptionalKey($i),
);
}
$newTypes[] = $newTypeBuilder->getArray();
}
return TypeCombinator::union(...$newTypes);
}
}
One example of TypeNodeResolverExtension usage is in the phpstan-phpunit extension. Before intersection types picked up the pace and were largely unknown to PHP community, developers often written Foo|MockObject when they actually meant Foo&MockObject. So the extension actually fixed it for them and made PHPStan interpret Foo|MockObject as an intersection type.
Late-resolvable types #
The Pick example above resolves its result eagerly - the moment the PHPDoc is parsed, it already knows the array shape and the keys, so it can compute the final type right away.
But that’s not always possible. Sometimes the final type depends on generics that are only known at each call site. Consider:
/**
* @template T of array
* @template K of key-of<T>
* @param T $array
* @param K $key
* @return T[K]
*/
function getOffset(array $array, int|string $key)
{
return $array[$key];
}
When PHPStan statically interprets the @return T[K] PHPDoc, both T and K are still template type variables - there’s no concrete array shape and no concrete key to look up yet. The return type can only be computed once the function is actually called and T and K get substituted with real types:
$r = getOffset(['a' => 1, 'b' => 'foo'], 'a');
\PHPStan\dumpType($r); // int
$r = getOffset(['a' => 1, 'b' => 'foo'], 'b');
\PHPStan\dumpType($r); // string
This is what late-resolvable types are for. Instead of resolving immediately, the type holds onto its sub-types and resolves itself later, after the template types have been substituted at the call site.
A late-resolvable type implements the LateResolvableType interface and uses the LateResolvableTypeTrait:
namespace PHPStan\Type;
interface LateResolvableType
{
public function resolve(): Type;
public function isResolvable(): bool;
}
The trait implements the whole Type interface for you by delegating every method to resolve(). You only need to provide three things:
isResolvable()- returnsfalsewhile the type still contains unresolved template types,trueonce everything is concrete.getResult()- computes the actual type. The trait calls this fromresolve()(and caches the result).- the usual
Typeboilerplate that can’t be delegated, likeequals(),describe(),traverse()andtoPhpDocNode().
OffsetAccessType (the T[K] from the example above) is a good illustration of how little code this takes:
final class OffsetAccessType implements CompoundType, LateResolvableType
{
use LateResolvableTypeTrait;
use NonGeneralizableTypeTrait;
public function __construct(
private Type $type,
private Type $offset,
)
{
}
public function isResolvable(): bool
{
return !TypeUtils::containsTemplateType($this->type)
&& !TypeUtils::containsTemplateType($this->offset);
}
protected function getResult(): Type
{
return $this->type->getOffsetValueType($this->offset);
}
// equals(), describe(), traverse(), toPhpDocNode() etc.
}
While isResolvable() is false, the type stays as T[K] and travels through the analysis untouched; once the call-site substitution makes it resolvable, getResult() turns it into the concrete value type at that offset.
PHPStan ships several built-in late-resolvable types you can study as references - they all live in phpstan-src and follow the same pattern:
KeyOfType-key-of<T>ValueOfType-value-of<T>OffsetAccessType-T[K]NewObjectType-new<T>ClassConstantAccessType-T::SOME_CONSTANTConditionalType- conditional return types like(T is string ? int : float)ConditionalTypeForParameter- conditional types keyed on a parameter, like($size is positive-int ? non-empty-list<T> : list<T>)
So when your TypeNodeResolverExtension builds a custom type whose result depends on generics, return a late-resolvable type from resolve() instead of trying to compute the final type up front.