From 1013c9f34df5e36c59be8e7fdcecccfe270bc3a4 Mon Sep 17 00:00:00 2001 From: shmax Date: Wed, 1 Apr 2026 07:46:18 -0700 Subject: [PATCH 01/25] first pass --- .idea/icon.png | Bin 23688 -> 0 bytes composer.json | 5 +- .../phpdoc-parser-generic-type-aliases.patch | 0 src/PhpDoc/PhpDocNodeResolver.php | 2 +- src/PhpDoc/Tag/TypeAliasTag.php | 7 + src/PhpDoc/TypeNodeResolver.php | 32 ++++ src/Type/Generic/TemplateTypeScope.php | 5 + src/Type/TypeAlias.php | 117 ++++++++++++++- .../Analyser/nsrt/generic-type-aliases.php | 142 ++++++++++++++++++ .../Rules/Classes/data/local-type-aliases.php | 11 ++ 10 files changed, 316 insertions(+), 5 deletions(-) delete mode 100644 .idea/icon.png create mode 100644 patches/phpdoc-parser-generic-type-aliases.patch create mode 100644 tests/PHPStan/Analyser/nsrt/generic-type-aliases.php diff --git a/.idea/icon.png b/.idea/icon.png deleted file mode 100644 index 5f346e71c16a945bb6edd669abf726694fabef18..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23688 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 AH~;_u diff --git a/composer.json b/composer.json index 3e58a44cc38..02af6ab271a 100644 --- a/composer.json +++ b/composer.json @@ -128,7 +128,10 @@ "patches/DependencyChecker.patch", "patches/Resolver.patch" ], - "symfony/console": [ + "phpstan/phpdoc-parser": [ + "patches/phpdoc-parser-generic-type-aliases.patch" + ], + "symfony/console": [ "patches/OutputFormatter.patch" ] } diff --git a/patches/phpdoc-parser-generic-type-aliases.patch b/patches/phpdoc-parser-generic-type-aliases.patch new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/PhpDoc/PhpDocNodeResolver.php b/src/PhpDoc/PhpDocNodeResolver.php index 1e88dce58aa..e9c086bdee2 100644 --- a/src/PhpDoc/PhpDocNodeResolver.php +++ b/src/PhpDoc/PhpDocNodeResolver.php @@ -522,7 +522,7 @@ public function resolveTypeAliasTags(PhpDocNode $phpDocNode, NameScope $nameScop foreach ($phpDocNode->getTypeAliasTagValues($tagName) as $typeAliasTagValue) { $alias = $typeAliasTagValue->alias; $typeNode = $typeAliasTagValue->type; - $resolved[$alias] = new TypeAliasTag($alias, $typeNode, $nameScope); + $resolved[$alias] = new TypeAliasTag($alias, $typeNode, $nameScope, $typeAliasTagValue->templateTypes); } } diff --git a/src/PhpDoc/Tag/TypeAliasTag.php b/src/PhpDoc/Tag/TypeAliasTag.php index d5cd10e5d68..df8fd58aa77 100644 --- a/src/PhpDoc/Tag/TypeAliasTag.php +++ b/src/PhpDoc/Tag/TypeAliasTag.php @@ -3,6 +3,7 @@ namespace PHPStan\PhpDoc\Tag; use PHPStan\Analyser\NameScope; +use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\Type\TypeAlias; @@ -12,10 +13,14 @@ final class TypeAliasTag { + /** + * @param TemplateTagValueNode[] $templateTagValueNodes + */ public function __construct( private string $aliasName, private TypeNode $typeNode, private NameScope $nameScope, + private array $templateTagValueNodes = [], ) { } @@ -30,6 +35,8 @@ public function getTypeAlias(): TypeAlias return new TypeAlias( $this->typeNode, $this->nameScope, + $this->templateTagValueNodes, + $this->aliasName, ); } diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index 8af74bf9264..d4f1f1530d8 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -104,6 +104,7 @@ use PHPStan\Type\Type; use PHPStan\Type\TypeAliasResolver; use PHPStan\Type\TypeAliasResolverProvider; +use PHPStan\Type\TypeAlias; use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeUtils; use PHPStan\Type\UnionType; @@ -828,6 +829,13 @@ static function (string $variance): TemplateTypeVariance { return new ErrorType(); } + // Check for a generic type alias (e.g. ProviderRequest) before + // falling through to class-based generic resolution. + $genericTypeAlias = $this->findGenericTypeAlias($typeNode->type->name, $nameScope); + if ($genericTypeAlias !== null) { + return $genericTypeAlias->resolveWithArgs($this, $genericTypes); + } + $mainType = $this->resolveIdentifierTypeNode($typeNode->type, $nameScope); $mainTypeObjectClassNames = $mainType->getObjectClassNames(); if (count($mainTypeObjectClassNames) > 1) { @@ -1360,4 +1368,28 @@ private function getTypeAliasResolver(): TypeAliasResolver return $this->typeAliasResolverProvider->getTypeAliasResolver(); } + /** + * Returns the TypeAlias for $name if it is a generic (parameterised) type alias + * visible in the current $nameScope, or null otherwise. + */ + private function findGenericTypeAlias(string $name, NameScope $nameScope): ?TypeAlias + { + if ($nameScope->shouldBypassTypeAliases()) { + return null; + } + + $className = $nameScope->getClassNameForTypeAlias(); + if ($className === null || !$this->getReflectionProvider()->hasClass($className)) { + return null; + } + + $typeAliases = $this->getReflectionProvider()->getClass($className)->getTypeAliases(); + if (!array_key_exists($name, $typeAliases)) { + return null; + } + + $typeAlias = $typeAliases[$name]; + return $typeAlias->isGeneric() ? $typeAlias : null; + } + } diff --git a/src/Type/Generic/TemplateTypeScope.php b/src/Type/Generic/TemplateTypeScope.php index f362ecadd4c..53773b49c9c 100644 --- a/src/Type/Generic/TemplateTypeScope.php +++ b/src/Type/Generic/TemplateTypeScope.php @@ -12,6 +12,11 @@ public static function createWithAnonymousFunction(): self return new self(null, null); } + public static function createWithTypeAlias(string $className, string $aliasName): self + { + return new self($className, '__typeAlias_' . $aliasName); + } + public static function createWithFunction(string $functionName): self { return new self(null, $functionName); diff --git a/src/Type/TypeAlias.php b/src/Type/TypeAlias.php index 17bd6373e57..b3f924aa972 100644 --- a/src/Type/TypeAlias.php +++ b/src/Type/TypeAlias.php @@ -3,18 +3,34 @@ namespace PHPStan\Type; use PHPStan\Analyser\NameScope; +use PHPStan\PhpDoc\Tag\TemplateTag; use PHPStan\PhpDoc\TypeNodeResolver; +use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; +use PHPStan\Type\Generic\TemplateTypeFactory; +use PHPStan\Type\Generic\TemplateTypeHelper; +use PHPStan\Type\Generic\TemplateTypeMap; +use PHPStan\Type\Generic\TemplateTypeScope; +use PHPStan\Type\Generic\TemplateTypeVariance; +use PHPStan\Type\Generic\TemplateTypeVarianceMap; +use function array_map; +use function array_values; +use function count; final class TypeAlias { private ?Type $resolvedType = null; + /** + * @param TemplateTagValueNode[] $templateTagValueNodes + */ public function __construct( private TypeNode $typeNode, private NameScope $nameScope, + private array $templateTagValueNodes = [], + private string $aliasName = '', ) { } @@ -26,12 +42,107 @@ public static function invalid(): self return $self; } + /** + * Returns the type with TemplateType placeholders for any declared template params. + * For non-generic aliases this is the fully-resolved concrete type. + */ public function resolve(TypeNodeResolver $typeNodeResolver): Type { - return $this->resolvedType ??= $typeNodeResolver->resolve( - $this->typeNode, - $this->nameScope, + if ($this->resolvedType !== null) { + return $this->resolvedType; + } + + $nameScope = $this->nameScope; + + if (count($this->templateTagValueNodes) > 0) { + $nameScope = $this->buildNameScopeWithTemplates($typeNodeResolver, $nameScope); + } + + return $this->resolvedType = $typeNodeResolver->resolve($this->typeNode, $nameScope); + } + + /** Whether this alias was declared with type parameters (e.g. @phpstan-type Foo). */ + public function isGeneric(): bool + { + return count($this->templateTagValueNodes) > 0; + } + + /** + * @return TemplateTagValueNode[] + */ + public function getTemplateTagValueNodes(): array + { + return $this->templateTagValueNodes; + } + + /** + * Resolves the alias body substituting concrete $args for each declared template parameter. + * + * @param Type[] $args Concrete types in the same order as the declared template params. + */ + public function resolveWithArgs(TypeNodeResolver $typeNodeResolver, array $args): Type + { + $resolvedType = $this->resolve($typeNodeResolver); + + if (count($this->templateTagValueNodes) === 0) { + return $resolvedType; + } + + // Map each template param name to the supplied arg (or its declared default / upper bound). + $templateTypeMapTypes = []; + foreach (array_values($this->templateTagValueNodes) as $i => $templateTagValueNode) { + if (isset($args[$i])) { + $templateTypeMapTypes[$templateTagValueNode->name] = $args[$i]; + } else { + $bound = $templateTagValueNode->bound !== null + ? $typeNodeResolver->resolve($templateTagValueNode->bound, $this->nameScope) + : new MixedType(true); + $default = $templateTagValueNode->default !== null + ? $typeNodeResolver->resolve($templateTagValueNode->default, $this->nameScope) + : null; + $templateTypeMapTypes[$templateTagValueNode->name] = $default ?? $bound; + } + } + + return TemplateTypeHelper::resolveTemplateTypes( + $resolvedType, + new TemplateTypeMap($templateTypeMapTypes), + TemplateTypeVarianceMap::createEmpty(), + TemplateTypeVariance::createInvariant(), ); } + /** + * Builds a NameScope augmented with TemplateType placeholders for each declared template param, + * so the alias body can reference them (e.g. `TFilter` resolves to a TemplateType). + */ + private function buildNameScopeWithTemplates(TypeNodeResolver $typeNodeResolver, NameScope $nameScope): NameScope + { + $templateTags = []; + foreach ($this->templateTagValueNodes as $templateTagValueNode) { + $templateTags[$templateTagValueNode->name] = new TemplateTag( + $templateTagValueNode->name, + $templateTagValueNode->bound !== null + ? $typeNodeResolver->resolve($templateTagValueNode->bound, $nameScope) + : new MixedType(true), + $templateTagValueNode->default !== null + ? $typeNodeResolver->resolve($templateTagValueNode->default, $nameScope) + : null, + TemplateTypeVariance::createInvariant(), + ); + } + + $className = $nameScope->getClassNameForTypeAlias(); + $templateTypeScope = ($className !== null && $this->aliasName !== '') + ? TemplateTypeScope::createWithTypeAlias($className, $this->aliasName) + : TemplateTypeScope::createWithAnonymousFunction(); + + $templateTypeMap = new TemplateTypeMap(array_map( + static fn (TemplateTag $tag): Type => TemplateTypeFactory::fromTemplateTag($templateTypeScope, $tag), + $templateTags, + )); + + return $nameScope->withTemplateTypeMap($templateTypeMap, $templateTags); + } + } diff --git a/tests/PHPStan/Analyser/nsrt/generic-type-aliases.php b/tests/PHPStan/Analyser/nsrt/generic-type-aliases.php new file mode 100644 index 00000000000..301d38c0914 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/generic-type-aliases.php @@ -0,0 +1,142 @@ + = array> array{ + * filters?: TFilter, + * limit?: int, + * offset?: int, + * } + */ +abstract class Provider +{ + /** + * @param Request $request + */ + abstract public function find(array $request): void; +} + +class ConcreteProvider extends Provider +{ + public function find(array $request): void + { + // Access an optional key – PHPStan represents the array{filters?:Filter,...} type + // as a union of the possible ConstantArrayType shapes (with/without the optional key). + // The important thing is that Filter IS substituted: `filters` carries array{skuId?: int, condition?: string}. + assertType('array{filters?: array{skuId?: int, condition?: string}, limit?: int, offset?: int}', $request); + } +} + +// ------------------------------------------------------- +// Direct usage in the same class – simpler and more reliable test +// ------------------------------------------------------- + +/** + * @phpstan-type AppraisalFilter array{skuId?: int, condition?: string} + * + * @phpstan-type ProviderRequest> array{ + * filters?: TFilter, + * limit?: int, + * offset?: int, + * } + */ +class DirectUsage +{ + /** + * @param ProviderRequest $request + */ + public function find(array $request): void + { + assertType('array{filters?: array{skuId?: int, condition?: string}, limit?: int, offset?: int}', $request); + } +} + +// ------------------------------------------------------- +// Test with list +// ------------------------------------------------------- + +/** + * @phpstan-type Paged array{items: list, total: int} + */ +class Repo +{ + /** + * @param Paged<\stdClass> $result + */ + public function check(array $result): void + { + assertType('list', $result['items']); + assertType('int', $result['total']); + } +} + +// ------------------------------------------------------- +// Test with two template params +// ------------------------------------------------------- + +/** + * @phpstan-type Map array + */ +class MapHolder +{ + /** + * @param Map $m + */ + public function check(array $m): void + { + assertType('array', $m); + } +} + +// ------------------------------------------------------- +// Test with default template param value +// ------------------------------------------------------- + +/** + * @phpstan-type WithDefault array{value: T} + */ +class DefaultHolder +{ + /** + * @param WithDefault $withInt + */ + public function check(array $withInt): void + { + assertType('int', $withInt['value']); + } +} + +// ------------------------------------------------------- +// Test @phpstan-import-type of a generic alias +// ------------------------------------------------------- + +/** + * @phpstan-import-type Map from MapHolder + * @phpstan-import-type Paged from Repo + */ +class ImportConsumer +{ + /** + * @param Map $m + */ + public function mapCheck(array $m): void + { + assertType('array', $m); + } + + /** + * @param Paged<\DateTime> $p + */ + public function pagedCheck(array $p): void + { + assertType('list', $p['items']); + assertType('int', $p['total']); + } +} + + diff --git a/tests/PHPStan/Rules/Classes/data/local-type-aliases.php b/tests/PHPStan/Rules/Classes/data/local-type-aliases.php index 152e77d8d7b..40096ec6208 100644 --- a/tests/PHPStan/Rules/Classes/data/local-type-aliases.php +++ b/tests/PHPStan/Rules/Classes/data/local-type-aliases.php @@ -104,3 +104,14 @@ class GenericsCheck { } + +/** + * Generic type alias – template params should not trigger "invalid type" errors. + * + * @phpstan-type GenericShape array{item: TItem, extra: TExtra} + */ +class GenericAlias +{ + +} + From 3a7cc4f46449c9d991f9ce9542465d5e5d255d9c Mon Sep 17 00:00:00 2001 From: shmax Date: Wed, 1 Apr 2026 08:53:12 -0700 Subject: [PATCH 02/25] enforce required generic params --- src/Rules/Classes/LocalTypeAliasesCheck.php | 16 ++++ src/Rules/Classes/MethodTagCheck.php | 13 +++ src/Rules/Classes/MixinCheck.php | 10 +++ src/Rules/Classes/PropertyTagCheck.php | 13 +++ .../MissingClassConstantTypehintRule.php | 12 +++ .../MissingFunctionParameterTypehintRule.php | 12 +++ .../MissingFunctionReturnTypehintRule.php | 11 +++ .../MissingMethodParameterTypehintRule.php | 13 +++ .../MissingMethodReturnTypehintRule.php | 12 +++ .../Methods/MissingMethodSelfOutTypeRule.php | 13 +++ src/Rules/MissingTypehintCheck.php | 26 ++++++ src/Rules/PhpDoc/AssertRuleHelper.php | 12 +++ .../PhpDoc/InvalidPhpDocVarTagTypeRule.php | 11 +++ .../MissingPropertyTypehintRule.php | 12 +++ .../SetPropertyHookParameterRule.php | 13 +++ src/Type/Generic/TemplateTypeScope.php | 19 +++++ test-generic-aliases.php | 82 +++++++++++++++++++ test-type-error-demo.php | 26 ++++++ 18 files changed, 326 insertions(+) create mode 100644 test-generic-aliases.php create mode 100644 test-type-error-demo.php diff --git a/src/Rules/Classes/LocalTypeAliasesCheck.php b/src/Rules/Classes/LocalTypeAliasesCheck.php index a849ecac8c4..6addd7998a6 100644 --- a/src/Rules/Classes/LocalTypeAliasesCheck.php +++ b/src/Rules/Classes/LocalTypeAliasesCheck.php @@ -221,6 +221,22 @@ public function checkInTraitDefinitionContext(ClassReflection $reflection): arra ->build(); } + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($resolvedType) as [$innerAliasName, $missingParams]) { + if ($innerAliasName === $aliasName) { + continue; // alias body contains its own template type placeholders — not a raw usage + } + $errors[] = RuleErrorBuilder::message(sprintf( + '%s %s has type alias %s with generic type alias %s but does not specify its types: %s', + $reflection->getClassTypeDescription(), + $reflection->getDisplayName(), + $aliasName, + $innerAliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($resolvedType) as $callableType) { $errors[] = RuleErrorBuilder::message(sprintf( '%s %s has type alias %s with no signature specified for %s.', diff --git a/src/Rules/Classes/MethodTagCheck.php b/src/Rules/Classes/MethodTagCheck.php index 88e5e3a4508..b5f863c8a7b 100644 --- a/src/Rules/Classes/MethodTagCheck.php +++ b/src/Rules/Classes/MethodTagCheck.php @@ -190,6 +190,19 @@ private function checkMethodTypeInTraitDefinitionContext(ClassReflection $classR ->build(); } + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($type) as [$aliasName, $missingParams]) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag @method for method %s::%s() %s contains generic type alias %s but does not specify its types: %s', + $classReflection->getDisplayName(), + $methodName, + $description, + $aliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($type) as $iterableType) { $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); $errors[] = RuleErrorBuilder::message(sprintf( diff --git a/src/Rules/Classes/MixinCheck.php b/src/Rules/Classes/MixinCheck.php index ecdfff0d92b..219981c13cc 100644 --- a/src/Rules/Classes/MixinCheck.php +++ b/src/Rules/Classes/MixinCheck.php @@ -99,6 +99,16 @@ public function checkInTraitDefinitionContext(ClassReflection $classReflection): ->build(); } + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($type) as [$aliasName, $missingParams]) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag @mixin contains generic type alias %s but does not specify its types: %s', + $aliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($type) as $callableType) { $errors[] = RuleErrorBuilder::message(sprintf( '%s %s has PHPDoc tag @mixin with no signature specified for %s.', diff --git a/src/Rules/Classes/PropertyTagCheck.php b/src/Rules/Classes/PropertyTagCheck.php index 6b4a42c905a..19a10ac63ac 100644 --- a/src/Rules/Classes/PropertyTagCheck.php +++ b/src/Rules/Classes/PropertyTagCheck.php @@ -171,6 +171,19 @@ private function checkPropertyTypeInTraitDefinitionContext(ClassReflection $clas ->build(); } + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($type) as [$aliasName, $missingParams]) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s for property %s::$%s contains generic type alias %s but does not specify its types: %s', + $tagName, + $classReflection->getDisplayName(), + $propertyName, + $aliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($type) as $iterableType) { $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); $errors[] = RuleErrorBuilder::message(sprintf( diff --git a/src/Rules/Constants/MissingClassConstantTypehintRule.php b/src/Rules/Constants/MissingClassConstantTypehintRule.php index bb2d10164bc..035990ea50d 100644 --- a/src/Rules/Constants/MissingClassConstantTypehintRule.php +++ b/src/Rules/Constants/MissingClassConstantTypehintRule.php @@ -83,6 +83,18 @@ private function processSingleConstant(ClassReflection $classReflection, string ->build(); } + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($constantType) as [$aliasName, $missingParams]) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Constant %s::%s with generic type alias %s does not specify its types: %s', + $constantReflection->getDeclaringClass()->getDisplayName(), + $constantName, + $aliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($constantType) as $callableType) { $errors[] = RuleErrorBuilder::message(sprintf( 'Constant %s::%s type has no signature specified for %s.', diff --git a/src/Rules/Functions/MissingFunctionParameterTypehintRule.php b/src/Rules/Functions/MissingFunctionParameterTypehintRule.php index 1246dc81e9c..7eb62f17195 100644 --- a/src/Rules/Functions/MissingFunctionParameterTypehintRule.php +++ b/src/Rules/Functions/MissingFunctionParameterTypehintRule.php @@ -105,6 +105,18 @@ private function checkFunctionParameter(FunctionReflection $functionReflection, ->build(); } + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($parameterType) as [$aliasName, $missingParams]) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Function %s() has %s with generic type alias %s but does not specify its types: %s', + $functionReflection->getName(), + $parameterMessage, + $aliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($parameterType) as $callableType) { $messages[] = RuleErrorBuilder::message(sprintf( 'Function %s() has %s with no signature specified for %s.', diff --git a/src/Rules/Functions/MissingFunctionReturnTypehintRule.php b/src/Rules/Functions/MissingFunctionReturnTypehintRule.php index 0fd30c79f99..c58fec56ac5 100644 --- a/src/Rules/Functions/MissingFunctionReturnTypehintRule.php +++ b/src/Rules/Functions/MissingFunctionReturnTypehintRule.php @@ -67,6 +67,17 @@ public function processNode(Node $node, Scope $scope): array ->build(); } + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($returnType) as [$aliasName, $missingParams]) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Function %s() return type with generic type alias %s does not specify its types: %s', + $functionReflection->getName(), + $aliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($returnType) as $callableType) { $messages[] = RuleErrorBuilder::message(sprintf( 'Function %s() return type has no signature specified for %s.', diff --git a/src/Rules/Methods/MissingMethodParameterTypehintRule.php b/src/Rules/Methods/MissingMethodParameterTypehintRule.php index 38516866320..9021a158a18 100644 --- a/src/Rules/Methods/MissingMethodParameterTypehintRule.php +++ b/src/Rules/Methods/MissingMethodParameterTypehintRule.php @@ -108,6 +108,19 @@ private function checkMethodParameter(MethodReflection $methodReflection, string ->build(); } + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($parameterType) as [$aliasName, $missingParams]) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Method %s::%s() has %s with generic type alias %s but does not specify its types: %s', + $methodReflection->getDeclaringClass()->getDisplayName(), + $methodReflection->getName(), + $parameterMessage, + $aliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($parameterType) as $callableType) { $messages[] = RuleErrorBuilder::message(sprintf( 'Method %s::%s() has %s with no signature specified for %s.', diff --git a/src/Rules/Methods/MissingMethodReturnTypehintRule.php b/src/Rules/Methods/MissingMethodReturnTypehintRule.php index e127a143613..f37c2631606 100644 --- a/src/Rules/Methods/MissingMethodReturnTypehintRule.php +++ b/src/Rules/Methods/MissingMethodReturnTypehintRule.php @@ -79,6 +79,18 @@ public function processNode(Node $node, Scope $scope): array ->build(); } + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($returnType) as [$aliasName, $missingParams]) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Method %s::%s() return type with generic type alias %s does not specify its types: %s', + $methodReflection->getDeclaringClass()->getDisplayName(), + $methodReflection->getName(), + $aliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($returnType) as $callableType) { $messages[] = RuleErrorBuilder::message(sprintf( 'Method %s::%s() return type has no signature specified for %s.', diff --git a/src/Rules/Methods/MissingMethodSelfOutTypeRule.php b/src/Rules/Methods/MissingMethodSelfOutTypeRule.php index 63966055b63..6db302e8573 100644 --- a/src/Rules/Methods/MissingMethodSelfOutTypeRule.php +++ b/src/Rules/Methods/MissingMethodSelfOutTypeRule.php @@ -72,6 +72,19 @@ public function processNode(Node $node, Scope $scope): array ->build(); } + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($selfOutType) as [$aliasName, $missingParams]) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Method %s::%s() has %s with generic type alias %s but does not specify its types: %s', + $classReflection->getDisplayName(), + $methodReflection->getName(), + $phpDocTagMessage, + $aliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($selfOutType) as $callableType) { $messages[] = RuleErrorBuilder::message(sprintf( 'Method %s::%s() has %s with no signature specified for %s.', diff --git a/src/Rules/MissingTypehintCheck.php b/src/Rules/MissingTypehintCheck.php index 07f584fb8a1..6f6e9ff8acb 100644 --- a/src/Rules/MissingTypehintCheck.php +++ b/src/Rules/MissingTypehintCheck.php @@ -27,6 +27,7 @@ use function array_filter; use function array_keys; use function array_merge; +use function array_unique; use function count; use function implode; use function in_array; @@ -170,6 +171,31 @@ public function getNonGenericObjectTypesWithGenericClass(Type $type): array return $objectTypes; } + /** + * @return list List of [aliasName, missingTypeParamNames] + */ + public function getRawGenericTypeAliasesUsage(Type $type): array + { + /** @var array> $found */ + $found = []; + TypeTraverser::map($type, static function (Type $type, callable $traverse) use (&$found): Type { + if ($type instanceof TemplateType) { + $aliasName = $type->getScope()->getTypeAliasName(); + if ($aliasName !== null && $type->getDefault() === null) { + $found[$aliasName][] = $type->getName(); + } + return $type; + } + return $traverse($type); + }); + + $result = []; + foreach ($found as $aliasName => $paramNames) { + $result[] = [$aliasName, implode(', ', array_unique($paramNames))]; + } + return $result; + } + /** * @return Type[] */ diff --git a/src/Rules/PhpDoc/AssertRuleHelper.php b/src/Rules/PhpDoc/AssertRuleHelper.php index 0dc14b638ae..015f8ce2ced 100644 --- a/src/Rules/PhpDoc/AssertRuleHelper.php +++ b/src/Rules/PhpDoc/AssertRuleHelper.php @@ -200,6 +200,18 @@ public function check( ->build(); } + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($assertedType) as [$aliasName, $missingParams]) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s for %s contains generic type alias %s but does not specify its types: %s', + $tagName, + $assertedExprString, + $aliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($assertedType) as $callableType) { $errors[] = RuleErrorBuilder::message(sprintf( 'PHPDoc tag %s for %s has no signature specified for %s.', diff --git a/src/Rules/PhpDoc/InvalidPhpDocVarTagTypeRule.php b/src/Rules/PhpDoc/InvalidPhpDocVarTagTypeRule.php index 7d6090f70a0..26ff9dbaea2 100644 --- a/src/Rules/PhpDoc/InvalidPhpDocVarTagTypeRule.php +++ b/src/Rules/PhpDoc/InvalidPhpDocVarTagTypeRule.php @@ -121,6 +121,17 @@ public function processNode(Node $node, Scope $scope): array ->identifier('missingType.generics') ->build(); } + + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($varTagType) as [$aliasName, $missingParams]) { + $errors[] = RuleErrorBuilder::message(sprintf( + '%s contains generic type alias %s but does not specify its types: %s', + $identifier, + $aliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } } $escapedIdentifier = SprintfHelper::escapeFormatString($identifier); diff --git a/src/Rules/Properties/MissingPropertyTypehintRule.php b/src/Rules/Properties/MissingPropertyTypehintRule.php index 889092b699b..d6ac5c7aae9 100644 --- a/src/Rules/Properties/MissingPropertyTypehintRule.php +++ b/src/Rules/Properties/MissingPropertyTypehintRule.php @@ -77,6 +77,18 @@ public function processNode(Node $node, Scope $scope): array ->build(); } + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($propertyType) as [$aliasName, $missingParams]) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Property %s::$%s with generic type alias %s does not specify its types: %s', + $propertyReflection->getDeclaringClass()->getDisplayName(), + $node->getName(), + $aliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($propertyType) as $callableType) { $messages[] = RuleErrorBuilder::message(sprintf( 'Property %s::$%s type has no signature specified for %s.', diff --git a/src/Rules/Properties/SetPropertyHookParameterRule.php b/src/Rules/Properties/SetPropertyHookParameterRule.php index 82f89362b82..63f3c7f2ba7 100644 --- a/src/Rules/Properties/SetPropertyHookParameterRule.php +++ b/src/Rules/Properties/SetPropertyHookParameterRule.php @@ -146,6 +146,19 @@ public function processNode(Node $node, Scope $scope): array ->build(); } + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($parameterType) as [$aliasName, $missingParams]) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Set hook for property %s::$%s has parameter $%s with generic type alias %s but does not specify its types: %s', + $classReflection->getDisplayName(), + $hookReflection->getHookedPropertyName(), + $parameter->getName(), + $aliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($parameterType) as $callableType) { $errors[] = RuleErrorBuilder::message(sprintf( 'Set hook for property %s::$%s has parameter $%s with no signature specified for %s.', diff --git a/src/Type/Generic/TemplateTypeScope.php b/src/Type/Generic/TemplateTypeScope.php index 53773b49c9c..04185f2fde4 100644 --- a/src/Type/Generic/TemplateTypeScope.php +++ b/src/Type/Generic/TemplateTypeScope.php @@ -3,6 +3,9 @@ namespace PHPStan\Type\Generic; use function sprintf; +use function str_starts_with; +use function strlen; +use function substr; final class TemplateTypeScope { @@ -48,6 +51,22 @@ public function getFunctionName(): ?string return $this->functionName; } + /** @api */ + public function isTypeAlias(): bool + { + return $this->functionName !== null && str_starts_with($this->functionName, '__typeAlias_'); + } + + /** @api */ + public function getTypeAliasName(): ?string + { + if (!$this->isTypeAlias()) { + return null; + } + + return substr($this->functionName, strlen('__typeAlias_')); + } + /** @api */ public function equals(self $other): bool { diff --git a/test-generic-aliases.php b/test-generic-aliases.php new file mode 100644 index 00000000000..96ba2b5b715 --- /dev/null +++ b/test-generic-aliases.php @@ -0,0 +1,82 @@ + + * @phpstan-type ProviderRequest array{ + * filters?: TFilter, + * limit?: int, + * offset?: int, + * } + */ +abstract class Provider +{ + /** + * @param ProviderRequest $request + * @return array + */ + public function find(array $request): array { + return []; + } +} + +/** + * @phpstan-type AppraisalFilter array{skuId?: int, condition?: string} + * @extends Provider + */ +final class SkuProvider extends Provider +{ + #[\Override] + public function find(array $request): array + { +// dumpType($request); + // PHPStan now knows $request is array{filters?: array{skuId?: int, condition?: string}, ...} + $filters = $request['filters'] ?? []; + + // This is int|null, not mixed! + $skuId = $filters['skuId'] ?? null; + + return [$skuId]; + } +} + +// --------------------------------------------------------------------------- +// Two-param alias +// --------------------------------------------------------------------------- + +/** + * @phpstan-type Pair array{first: TFirst, second: TSecond} + */ +final class PairHolder +{ + /** + * @param Pair $pair + */ + public function use(array $pair): void + { + echo $pair['first']; // string + echo $pair['second']; // int + } +} + +// --------------------------------------------------------------------------- +// With default +// --------------------------------------------------------------------------- + +/** + * @phpstan-type Response> array{data: TData, status: int} + */ +final class ApiClient +{ + /** + * @return Response + */ + public function getUser(): array + { + return ['data' => ['id' => 1, 'name' => 'Alice'], 'status' => 200]; + } +} diff --git a/test-type-error-demo.php b/test-type-error-demo.php new file mode 100644 index 00000000000..af48b18090a --- /dev/null +++ b/test-type-error-demo.php @@ -0,0 +1,26 @@ +> array{filters?: TFilter, limit?: int} + */ +final class ProviderTypeError +{ + /** + * @param Request $req + */ + public function find(array $req): void + { + $filters = $req['filters'] ?? []; + + // PHPStan now knows $filters is array{skuId?: int, condition?: string} + // so this arithmetic on int + string IS caught: + $bad = ($filters['skuId'] ?? 0) + 'hello'; // Error: binary + with string + + // And this wrong-type pass is also caught: + $this->takeString($filters['skuId'] ?? 0); // Error: passing int where string expected + } + + public function takeString(string $s): void {} +} + From 589aa11b0140a80520b16cbef5ed2dce7a0495a7 Mon Sep 17 00:00:00 2001 From: shmax Date: Wed, 1 Apr 2026 09:27:23 -0700 Subject: [PATCH 03/25] more tests --- test-generic-aliases.php | 150 ++++++++++++++++++ ...MissingMethodParameterTypehintRuleTest.php | 18 +++ .../MissingMethodReturnTypehintRuleTest.php | 10 ++ .../generic-type-alias-missing-typehint.php | 79 +++++++++ 4 files changed, 257 insertions(+) create mode 100644 tests/PHPStan/Rules/Methods/data/generic-type-alias-missing-typehint.php diff --git a/test-generic-aliases.php b/test-generic-aliases.php index 96ba2b5b715..9dde908b620 100644 --- a/test-generic-aliases.php +++ b/test-generic-aliases.php @@ -80,3 +80,153 @@ public function getUser(): array return ['data' => ['id' => 1, 'name' => 'Alice'], 'status' => 200]; } } + +// --------------------------------------------------------------------------- +// @return of generic alias +// --------------------------------------------------------------------------- + +/** + * @phpstan-type Page array{items: list, total: int, page: int} + */ +final class PagedRepo +{ + /** + * @return Page<\stdClass> + */ + public function getPage(): array + { + dumpType($this->getPage()); // should show array{items: list, total: int, page: int} + return ['items' => [], 'total' => 0, 'page' => 1]; + } +} + +// --------------------------------------------------------------------------- +// @var property annotation +// --------------------------------------------------------------------------- + +/** + * @phpstan-type Config array{key: string, value: TValue} + */ +final class Settings +{ + /** @var Config */ + public array $timeout = ['key' => 'timeout', 'value' => 30]; + + /** @var Config */ + public array $name = ['key' => 'name', 'value' => 'default']; + + public function check(): void + { + dumpType($this->timeout['value']); // int + dumpType($this->name['value']); // string + } +} + +// --------------------------------------------------------------------------- +// Nested generic alias (alias referencing another generic alias with type args) +// --------------------------------------------------------------------------- + +/** + * @phpstan-type Item array{id: int, data: T} + * @phpstan-type ItemList list> + */ +final class ItemRepo +{ + /** + * @param ItemList $items + */ + public function process(array $items): void + { + dumpType($items); // list + dumpType($items[0]['data']); // string + } +} + +// --------------------------------------------------------------------------- +// @phpstan-import-type of a generic alias, then used with type args +// --------------------------------------------------------------------------- + +/** + * @phpstan-import-type Pair from PairHolder + */ +final class PairConsumer +{ + /** + * @param Pair $p + */ + public function check(array $p): void + { + dumpType($p['first']); // int + dumpType($p['second']); // bool + } +} + +// --------------------------------------------------------------------------- +// Default type arg — using alias WITHOUT args should be OK (default kicks in) +// --------------------------------------------------------------------------- + +/** + * @phpstan-type WithDefault array{value: T} + */ +final class DefaultConsumer +{ + /** + * @param WithDefault $explicit no error: type arg provided + * @param WithDefault $implicit no error: T has a default + */ + public function check(array $explicit, array $implicit): void + { + dumpType($explicit['value']); // int + dumpType($implicit['value']); // BUG: shows raw TemplateType instead of string — default not applied when alias used without args + } +} + +// --------------------------------------------------------------------------- +// Generic alias in a standalone function (not a class method) +// --------------------------------------------------------------------------- + +/** + * @phpstan-type Range array{min: T, max: T} + */ +final class RangeHolder +{ + /** + * @param Range $r + * @return Range + */ + public function convert(array $r): array + { + dumpType($r['min']); // int + return ['min' => (float) $r['min'], 'max' => (float) $r['max']]; + } +} + +// --------------------------------------------------------------------------- +// Too many type args — should error +// --------------------------------------------------------------------------- + +/** + * @phpstan-type Single array{value: T} + */ +final class TooManyArgs +{ + /** + * @param Single $x TODO: should error — Single takes 1 type arg, 2 given (not yet detected) + */ + public function check(array $x): void {} +} + +// --------------------------------------------------------------------------- +// Too few required type args (partial application of multi-param alias) — should error +// --------------------------------------------------------------------------- + +/** + * @phpstan-type KeyValue array{key: TKey, value: TValue} + */ +final class TooFewArgs +{ + /** + * @param KeyValue $x TODO: should error — KeyValue requires 2 type args (not yet detected) + */ + public function check(array $x): void {} +} diff --git a/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php b/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php index db4b818f924..b4191c97f7c 100644 --- a/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php +++ b/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php @@ -148,4 +148,22 @@ public function testBug7662(): void ]); } + public function testGenericTypeAliasMissingTypehint(): void + { + $this->analyse([__DIR__ . '/data/generic-type-alias-missing-typehint.php'], [ + [ + 'Method GenericTypeAliasMissingTypehint\RawUsage::check() has parameter $b with generic type alias Filter but does not specify its types: TItem', + 18, + ], + [ + 'Method GenericTypeAliasMissingTypehint\PartialDefault::check() has parameter $noArgs with generic type alias Pair but does not specify its types: TFirst', + 61, + ], + [ + 'Method GenericTypeAliasMissingTypehint\ImportedRawUsage::check() has parameter $bad with generic type alias Filter but does not specify its types: TItem', + 77, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/MissingMethodReturnTypehintRuleTest.php b/tests/PHPStan/Rules/Methods/MissingMethodReturnTypehintRuleTest.php index 9b4d374395d..71ed8d3f6ab 100644 --- a/tests/PHPStan/Rules/Methods/MissingMethodReturnTypehintRuleTest.php +++ b/tests/PHPStan/Rules/Methods/MissingMethodReturnTypehintRuleTest.php @@ -128,4 +128,14 @@ public function testInheritPhpDocReturnTypeWithNarrowerNativeReturnType(): void $this->analyse([__DIR__ . '/data/inherit-phpdoc-return-type-with-narrower-native-return-type.php'], []); } + public function testGenericTypeAliasMissingTypehint(): void + { + $this->analyse([__DIR__ . '/data/generic-type-alias-missing-typehint.php'], [ + [ + 'Method GenericTypeAliasMissingTypehint\RawUsage::getRaw() return type with generic type alias Filter does not specify its types: TItem', + 28, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/generic-type-alias-missing-typehint.php b/tests/PHPStan/Rules/Methods/data/generic-type-alias-missing-typehint.php new file mode 100644 index 00000000000..e7dff97252a --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/generic-type-alias-missing-typehint.php @@ -0,0 +1,79 @@ + array{items: list} + */ +class RawUsage +{ + /** + * @param Filter $a OK: type arg provided + * @param Filter $b ERROR: Filter requires 1 type arg + */ + public function check(array $a, array $b): void {} + + /** + * @return Filter OK + */ + public function getFiltered(): array { return ['items' => []]; } + + /** + * @return Filter ERROR: Filter requires 1 type arg + */ + public function getRaw(): array { return ['items' => []]; } +} + +// --------------------------------------------------------------------------- +// Alias with a default — bare usage should NOT error +// --------------------------------------------------------------------------- + +/** + * @phpstan-type WithDefault array{value: T} + */ +class DefaultedUsage +{ + /** + * @param WithDefault $implicit OK: T has a default + * @param WithDefault $explicit OK: T provided + */ + public function check(array $implicit, array $explicit): void {} +} + +// --------------------------------------------------------------------------- +// Two-param alias, one required, one defaulted +// --------------------------------------------------------------------------- + +/** + * @phpstan-type Pair array{first: TFirst, second: TSecond} + */ +class PartialDefault +{ + /** + * @param Pair $oneArg OK: TFirst provided, TSecond defaults to bool + * @param Pair $twoArgs OK: both provided + * @param Pair $noArgs ERROR: TFirst has no default + */ + public function check(array $oneArg, array $twoArgs, array $noArgs): void {} +} + +// --------------------------------------------------------------------------- +// Imported generic alias — raw usage should also error +// --------------------------------------------------------------------------- + +/** + * @phpstan-import-type Filter from RawUsage + */ +class ImportedRawUsage +{ + /** + * @param Filter $ok OK + * @param Filter $bad ERROR: Filter requires 1 type arg + */ + public function check(array $ok, array $bad): void {} +} + From 59639725ada9a1e59228baff81e92cb285b273db Mon Sep 17 00:00:00 2001 From: shmax Date: Wed, 1 Apr 2026 09:55:49 -0700 Subject: [PATCH 04/25] tests for invalud usage --- src/PhpDoc/TypeNodeResolver.php | 19 ++++++ src/Type/TypeAlias.php | 37 +++++++++++ test-generic-aliases.php | 38 +++++------ ...MissingMethodParameterTypehintRuleTest.php | 16 +++++ .../MissingMethodReturnTypehintRuleTest.php | 16 +++++ .../PhpDoc/IncompatiblePhpDocTypeRuleTest.php | 22 +++++++ .../generic-type-alias-wrong-arg-count.php | 66 +++++++++++++++++++ 7 files changed, 194 insertions(+), 20 deletions(-) create mode 100644 tests/PHPStan/Rules/PhpDoc/data/generic-type-alias-wrong-arg-count.php diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index d4f1f1530d8..2c2af9fc27e 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -112,6 +112,7 @@ use PHPStan\Type\VoidType; use Traversable; use function array_key_exists; +use function array_filter; use function array_map; use function array_values; use function count; @@ -494,6 +495,15 @@ private function resolveIdentifierTypeNode(IdentifierTypeNode $typeNode, NameSco } if (!$nameScope->shouldBypassTypeAliases()) { + // Handle a generic alias referenced without type arguments. + // resolveWithDefaults() applies declared defaults so params with defaults are + // substituted, while required params remain as TemplateType placeholders + // (which getRawGenericTypeAliasesUsage() later detects and reports). + $genericAlias = $this->findGenericTypeAlias($typeNode->name, $nameScope); + if ($genericAlias !== null) { + return $genericAlias->resolveWithDefaults($this); + } + $typeAlias = $this->getTypeAliasResolver()->resolveTypeAlias($typeNode->name, $nameScope); if ($typeAlias !== null) { return $typeAlias; @@ -833,6 +843,15 @@ static function (string $variance): TemplateTypeVariance { // falling through to class-based generic resolution. $genericTypeAlias = $this->findGenericTypeAlias($typeNode->type->name, $nameScope); if ($genericTypeAlias !== null) { + $templateNodes = $genericTypeAlias->getTemplateTagValueNodes(); + $totalParams = count($templateNodes); + $requiredParams = count(array_filter($templateNodes, static fn ($tvn) => $tvn->default === null)); + $providedArgs = count($genericTypes); + + if ($providedArgs > $totalParams || $providedArgs < $requiredParams) { + return new ErrorType(); + } + return $genericTypeAlias->resolveWithArgs($this, $genericTypes); } diff --git a/src/Type/TypeAlias.php b/src/Type/TypeAlias.php index b3f924aa972..485eb33e4e4 100644 --- a/src/Type/TypeAlias.php +++ b/src/Type/TypeAlias.php @@ -8,12 +8,14 @@ use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; +use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeFactory; use PHPStan\Type\Generic\TemplateTypeHelper; use PHPStan\Type\Generic\TemplateTypeMap; use PHPStan\Type\Generic\TemplateTypeScope; use PHPStan\Type\Generic\TemplateTypeVariance; use PHPStan\Type\Generic\TemplateTypeVarianceMap; +use PHPStan\Type\TypeTraverser; use function array_map; use function array_values; use function count; @@ -112,6 +114,41 @@ public function resolveWithArgs(TypeNodeResolver $typeNodeResolver, array $args) ); } + /** + * Resolves the alias body applying default values for template params that declare one. + * Template params without defaults remain as TemplateType placeholders so that callers + * (e.g. MissingTypehintCheck) can detect bare generic alias usage. + */ + public function resolveWithDefaults(TypeNodeResolver $typeNodeResolver): Type + { + $baseType = $this->resolve($typeNodeResolver); + + if (count($this->templateTagValueNodes) === 0) { + return $baseType; + } + + // Collect default values for params that declare one. + $defaultsMap = []; + foreach ($this->templateTagValueNodes as $tvn) { + if ($tvn->default !== null) { + $defaultsMap[$tvn->name] = $typeNodeResolver->resolve($tvn->default, $this->nameScope); + } + } + + if (count($defaultsMap) === 0) { + return $baseType; + } + + // Replace only TemplateType instances scoped to THIS alias that have a declared default. + $aliasName = $this->aliasName; + return TypeTraverser::map($baseType, static function (Type $type, callable $traverse) use ($defaultsMap, $aliasName): Type { + if ($type instanceof TemplateType && $type->getScope()->getTypeAliasName() === $aliasName && isset($defaultsMap[$type->getName()])) { + return $defaultsMap[$type->getName()]; + } + return $traverse($type); + }); + } + /** * Builds a NameScope augmented with TemplateType placeholders for each declared template param, * so the alias body can reference them (e.g. `TFilter` resolves to a TemplateType). diff --git a/test-generic-aliases.php b/test-generic-aliases.php index 9dde908b620..6cd90a0efd9 100644 --- a/test-generic-aliases.php +++ b/test-generic-aliases.php @@ -3,7 +3,6 @@ // --------------------------------------------------------------------------- // Generic @phpstan-type demo // --------------------------------------------------------------------------- -use function PHPStan\dumpType; /** * @template ProviderFilter of array @@ -33,7 +32,6 @@ final class SkuProvider extends Provider #[\Override] public function find(array $request): array { -// dumpType($request); // PHPStan now knows $request is array{filters?: array{skuId?: int, condition?: string}, ...} $filters = $request['filters'] ?? []; @@ -58,8 +56,8 @@ final class PairHolder */ public function use(array $pair): void { - echo $pair['first']; // string - echo $pair['second']; // int + echo $pair['first']; // string + echo (string) $pair['second']; // int → cast to string for echo } } @@ -91,11 +89,10 @@ public function getUser(): array final class PagedRepo { /** - * @return Page<\stdClass> + * @return Page<\stdClass> // resolves to array{items: list, total: int, page: int} */ public function getPage(): array { - dumpType($this->getPage()); // should show array{items: list, total: int, page: int} return ['items' => [], 'total' => 0, 'page' => 1]; } } @@ -117,8 +114,8 @@ final class Settings public function check(): void { - dumpType($this->timeout['value']); // int - dumpType($this->name['value']); // string + // $this->timeout['value'] — int + // $this->name['value'] — string } } @@ -133,12 +130,11 @@ public function check(): void final class ItemRepo { /** - * @param ItemList $items + * @param ItemList $items // list */ public function process(array $items): void { - dumpType($items); // list - dumpType($items[0]['data']); // string + // $items[0]['data'] — string } } @@ -156,8 +152,8 @@ final class PairConsumer */ public function check(array $p): void { - dumpType($p['first']); // int - dumpType($p['second']); // bool + // $p['first'] — int + // $p['second'] — bool } } @@ -172,12 +168,12 @@ final class DefaultConsumer { /** * @param WithDefault $explicit no error: type arg provided - * @param WithDefault $implicit no error: T has a default + * @param WithDefault $implicit no error: T has a default (string) */ public function check(array $explicit, array $implicit): void { - dumpType($explicit['value']); // int - dumpType($implicit['value']); // BUG: shows raw TemplateType instead of string — default not applied when alias used without args + // $explicit['value'] — int + // $implicit['value'] — string (default applied ✓) } } @@ -191,12 +187,12 @@ public function check(array $explicit, array $implicit): void final class RangeHolder { /** - * @param Range $r + * @param Range $r * @return Range */ public function convert(array $r): array { - dumpType($r['min']); // int + // $r['min'] — int return ['min' => (float) $r['min'], 'max' => (float) $r['max']]; } } @@ -211,7 +207,8 @@ public function convert(array $r): array final class TooManyArgs { /** - * @param Single $x TODO: should error — Single takes 1 type arg, 2 given (not yet detected) + * @param Single $x ERROR: Single takes 1 type arg, 2 given + * @phpstan-ignore parameter.unresolvableType, missingType.iterableValue */ public function check(array $x): void {} } @@ -226,7 +223,8 @@ public function check(array $x): void {} final class TooFewArgs { /** - * @param KeyValue $x TODO: should error — KeyValue requires 2 type args (not yet detected) + * @param KeyValue $x ERROR: KeyValue requires 2 type args, 1 given + * @phpstan-ignore parameter.unresolvableType, missingType.iterableValue */ public function check(array $x): void {} } diff --git a/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php b/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php index b4191c97f7c..9d0bbb46a6c 100644 --- a/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php +++ b/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php @@ -166,4 +166,20 @@ public function testGenericTypeAliasMissingTypehint(): void ]); } + public function testGenericTypeAliasWrongArgCount(): void + { + $this->analyse([__DIR__ . '/../PhpDoc/data/generic-type-alias-wrong-arg-count.php'], [ + [ + 'Method GenericTypeAliasWrongArgCount\TooManyArgs::badParam() has parameter $x with no value type specified in iterable type array.', + 17, + MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP, + ], + [ + 'Method GenericTypeAliasWrongArgCount\TooFewArgs::badParam() has parameter $x with no value type specified in iterable type array.', + 47, + MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/MissingMethodReturnTypehintRuleTest.php b/tests/PHPStan/Rules/Methods/MissingMethodReturnTypehintRuleTest.php index 71ed8d3f6ab..b81a6698372 100644 --- a/tests/PHPStan/Rules/Methods/MissingMethodReturnTypehintRuleTest.php +++ b/tests/PHPStan/Rules/Methods/MissingMethodReturnTypehintRuleTest.php @@ -138,4 +138,20 @@ public function testGenericTypeAliasMissingTypehint(): void ]); } + public function testGenericTypeAliasWrongArgCount(): void + { + $this->analyse([__DIR__ . '/../PhpDoc/data/generic-type-alias-wrong-arg-count.php'], [ + [ + 'Method GenericTypeAliasWrongArgCount\TooManyArgs::badReturn() return type has no value type specified in iterable type array.', + 22, + MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP, + ], + [ + 'Method GenericTypeAliasWrongArgCount\TooFewArgs::badReturn() return type has no value type specified in iterable type array.', + 52, + MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php b/tests/PHPStan/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php index 247dcce92b8..7f07d3b93d1 100644 --- a/tests/PHPStan/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php @@ -497,4 +497,26 @@ public function testBug11463b(): void $this->analyse([__DIR__ . '/data/bug-11463b.php'], []); } + public function testGenericTypeAliasWrongArgCount(): void + { + $this->analyse([__DIR__ . '/data/generic-type-alias-wrong-arg-count.php'], [ + [ + 'PHPDoc tag @param for parameter $x contains unresolvable type.', + 17, + ], + [ + 'PHPDoc tag @return contains unresolvable type.', + 22, + ], + [ + 'PHPDoc tag @param for parameter $x contains unresolvable type.', + 47, + ], + [ + 'PHPDoc tag @return contains unresolvable type.', + 52, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/PhpDoc/data/generic-type-alias-wrong-arg-count.php b/tests/PHPStan/Rules/PhpDoc/data/generic-type-alias-wrong-arg-count.php new file mode 100644 index 00000000000..443c9d495dc --- /dev/null +++ b/tests/PHPStan/Rules/PhpDoc/data/generic-type-alias-wrong-arg-count.php @@ -0,0 +1,66 @@ + array{value: T} + */ +final class TooManyArgs +{ + /** + * @param Single $x + */ + public function badParam(array $x): void {} + + /** + * @return Single + */ + public function badReturn(): array { return ['value' => 1]; } + + /** + * @param Single $ok + */ + public function goodParam(array $ok): void {} + + /** + * @return Single + */ + public function goodReturn(): array { return ['value' => 1]; } +} + +// --------------------------------------------------------------------------- +// Too few required type args (partial application of multi-param alias) +// --------------------------------------------------------------------------- + +/** + * @phpstan-type KeyVal array{key: TKey, value: TValue} + */ +final class TooFewArgs +{ + /** + * @param KeyVal $x + */ + public function badParam(array $x): void {} + + /** + * @return KeyVal + */ + public function badReturn(): array { return ['key' => 'k', 'value' => 'v']; } + + /** + * @param KeyVal $ok + */ + public function goodParam(array $ok): void {} + + /** + * @return KeyVal + */ + public function goodReturn(): array { return ['key' => 'k', 'value' => 1]; } +} + + + From 8759befb6a1de9dc098e231ffe5377f10dff12c7 Mon Sep 17 00:00:00 2001 From: shmax Date: Wed, 1 Apr 2026 11:12:27 -0700 Subject: [PATCH 05/25] fix regression --- src/PhpDoc/TypeNodeResolver.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index 2c2af9fc27e..02a0cbfa758 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -1397,6 +1397,17 @@ private function findGenericTypeAlias(string $name, NameScope $nameScope): ?Type return null; } + // Fast path: if the name isn't registered as a type alias in this scope, skip the + // more expensive ClassReflection::getTypeAliases() call. This also prevents a circular + // NameScope-building issue: getTypeAliases() can trigger FileTypeMapper::getResolvedPhpDoc() + // which calls getNameScope() — if we are already inside getNameScope() for this class, + // that throws NameScopeAlreadyBeingCreatedException, causing the class's ResolvedPhpDocBlock + // to be poisoned with an empty block and all its type aliases to be lost. + // UsefulTypeAliasResolver uses the same guard. + if (!$nameScope->hasTypeAlias($name)) { + return null; + } + $className = $nameScope->getClassNameForTypeAlias(); if ($className === null || !$this->getReflectionProvider()->hasClass($className)) { return null; From cd30ab8386307f25f3b8227ce848e713d4bc27e2 Mon Sep 17 00:00:00 2001 From: shmax Date: Wed, 1 Apr 2026 11:30:30 -0700 Subject: [PATCH 06/25] add more scenarios to test fixture --- .../Analyser/nsrt/generic-type-aliases.php | 107 ++++++++++++++++-- 1 file changed, 100 insertions(+), 7 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/generic-type-aliases.php b/tests/PHPStan/Analyser/nsrt/generic-type-aliases.php index 301d38c0914..fe9bc243470 100644 --- a/tests/PHPStan/Analyser/nsrt/generic-type-aliases.php +++ b/tests/PHPStan/Analyser/nsrt/generic-type-aliases.php @@ -56,6 +56,70 @@ public function find(array $request): void } } +// ------------------------------------------------------- +// Two template params +// ------------------------------------------------------- + +/** + * @phpstan-type Pair array{first: TFirst, second: TSecond} + */ +class PairHolder +{ + /** + * @param Pair $pair + */ + public function check(array $pair): void + { + assertType('string', $pair['first']); + assertType('int', $pair['second']); + } +} + +// ------------------------------------------------------- +// @return of generic alias with bound constraint +// ------------------------------------------------------- + +/** + * @phpstan-type Range array{min: T, max: T} + */ +class RangeHolder +{ + /** + * @param Range $r + * @return Range + */ + public function convert(array $r): array + { + assertType('int', $r['min']); + assertType('int', $r['max']); + $result = ['min' => (float) $r['min'], 'max' => (float) $r['max']]; + assertType('array{min: float, max: float}', $result); + return $result; + } +} + +// ------------------------------------------------------- +// @var property annotation +// ------------------------------------------------------- + +/** + * @phpstan-type Config array{key: string, value: TValue} + */ +class Settings +{ + /** @var Config */ + public array $timeout = ['key' => 'timeout', 'value' => 30]; + + /** @var Config */ + public array $name = ['key' => 'name', 'value' => 'default']; + + public function check(): void + { + assertType('int', $this->timeout['value']); + assertType('string', $this->name['value']); + } +} + // ------------------------------------------------------- // Test with list // ------------------------------------------------------- @@ -75,6 +139,25 @@ public function check(array $result): void } } +// ------------------------------------------------------- +// Nested generic alias (alias referencing another generic alias) +// ------------------------------------------------------- + +/** + * @phpstan-type Item array{id: int, data: T} + * @phpstan-type ItemList list> + */ +class ItemRepo +{ + /** + * @param ItemList $items + */ + public function process(array $items): void + { + assertType('list', $items); + } +} + // ------------------------------------------------------- // Test with two template params // ------------------------------------------------------- @@ -94,7 +177,7 @@ public function check(array $m): void } // ------------------------------------------------------- -// Test with default template param value +// Default param: explicit arg vs bare usage (default applied) // ------------------------------------------------------- /** @@ -103,21 +186,24 @@ public function check(array $m): void class DefaultHolder { /** - * @param WithDefault $withInt + * @param WithDefault $explicit explicit arg overrides default + * @param WithDefault $implicit bare usage – T defaults to string */ - public function check(array $withInt): void + public function check(array $explicit, array $implicit): void { - assertType('int', $withInt['value']); + assertType('int', $explicit['value']); + assertType('string', $implicit['value']); } } // ------------------------------------------------------- -// Test @phpstan-import-type of a generic alias +// @phpstan-import-type of a generic alias // ------------------------------------------------------- /** * @phpstan-import-type Map from MapHolder * @phpstan-import-type Paged from Repo + * @phpstan-import-type Pair from PairHolder */ class ImportConsumer { @@ -137,6 +223,13 @@ public function pagedCheck(array $p): void assertType('list', $p['items']); assertType('int', $p['total']); } -} - + /** + * @param Pair $p + */ + public function pairCheck(array $p): void + { + assertType('int', $p['first']); + assertType('bool', $p['second']); + } +} From 3cdf8c312aa065616c2faea9fc30f6b7cf6a1fbc Mon Sep 17 00:00:00 2001 From: shmax Date: Wed, 1 Apr 2026 11:34:13 -0700 Subject: [PATCH 07/25] remove empty patch --- composer.json | 5 +---- patches/phpdoc-parser-generic-type-aliases.patch | 0 2 files changed, 1 insertion(+), 4 deletions(-) delete mode 100644 patches/phpdoc-parser-generic-type-aliases.patch diff --git a/composer.json b/composer.json index 02af6ab271a..3e58a44cc38 100644 --- a/composer.json +++ b/composer.json @@ -128,10 +128,7 @@ "patches/DependencyChecker.patch", "patches/Resolver.patch" ], - "phpstan/phpdoc-parser": [ - "patches/phpdoc-parser-generic-type-aliases.patch" - ], - "symfony/console": [ + "symfony/console": [ "patches/OutputFormatter.patch" ] } diff --git a/patches/phpdoc-parser-generic-type-aliases.patch b/patches/phpdoc-parser-generic-type-aliases.patch deleted file mode 100644 index e69de29bb2d..00000000000 From 6eaee2c841b87242fd4e96cbaf7f41f411b0c47e Mon Sep 17 00:00:00 2001 From: shmax Date: Wed, 1 Apr 2026 14:10:57 -0700 Subject: [PATCH 08/25] coalesce to empty array --- src/PhpDoc/PhpDocNodeResolver.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PhpDoc/PhpDocNodeResolver.php b/src/PhpDoc/PhpDocNodeResolver.php index e9c086bdee2..c25d0fa1286 100644 --- a/src/PhpDoc/PhpDocNodeResolver.php +++ b/src/PhpDoc/PhpDocNodeResolver.php @@ -522,7 +522,7 @@ public function resolveTypeAliasTags(PhpDocNode $phpDocNode, NameScope $nameScop foreach ($phpDocNode->getTypeAliasTagValues($tagName) as $typeAliasTagValue) { $alias = $typeAliasTagValue->alias; $typeNode = $typeAliasTagValue->type; - $resolved[$alias] = new TypeAliasTag($alias, $typeNode, $nameScope, $typeAliasTagValue->templateTypes); + $resolved[$alias] = new TypeAliasTag($alias, $typeNode, $nameScope, $typeAliasTagValue->templateTypes ?? []); } } From a2d234fde08d9308132c67155d646c115da7906d Mon Sep 17 00:00:00 2001 From: shmax Date: Wed, 1 Apr 2026 14:14:55 -0700 Subject: [PATCH 09/25] lint --- src/PhpDoc/TypeNodeResolver.php | 2 +- src/Type/TypeAlias.php | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index 02a0cbfa758..357e7025b76 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -102,9 +102,9 @@ use PHPStan\Type\StringType; use PHPStan\Type\ThisType; use PHPStan\Type\Type; +use PHPStan\Type\TypeAlias; use PHPStan\Type\TypeAliasResolver; use PHPStan\Type\TypeAliasResolverProvider; -use PHPStan\Type\TypeAlias; use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeUtils; use PHPStan\Type\UnionType; diff --git a/src/Type/TypeAlias.php b/src/Type/TypeAlias.php index 485eb33e4e4..9a9469e9d81 100644 --- a/src/Type/TypeAlias.php +++ b/src/Type/TypeAlias.php @@ -15,7 +15,6 @@ use PHPStan\Type\Generic\TemplateTypeScope; use PHPStan\Type\Generic\TemplateTypeVariance; use PHPStan\Type\Generic\TemplateTypeVarianceMap; -use PHPStan\Type\TypeTraverser; use function array_map; use function array_values; use function count; @@ -130,9 +129,10 @@ public function resolveWithDefaults(TypeNodeResolver $typeNodeResolver): Type // Collect default values for params that declare one. $defaultsMap = []; foreach ($this->templateTagValueNodes as $tvn) { - if ($tvn->default !== null) { - $defaultsMap[$tvn->name] = $typeNodeResolver->resolve($tvn->default, $this->nameScope); + if ($tvn->default === null) { + continue; } + $defaultsMap[$tvn->name] = $typeNodeResolver->resolve($tvn->default, $this->nameScope); } if (count($defaultsMap) === 0) { @@ -170,7 +170,7 @@ private function buildNameScopeWithTemplates(TypeNodeResolver $typeNodeResolver, } $className = $nameScope->getClassNameForTypeAlias(); - $templateTypeScope = ($className !== null && $this->aliasName !== '') + $templateTypeScope = $className !== null && $this->aliasName !== '' ? TemplateTypeScope::createWithTypeAlias($className, $this->aliasName) : TemplateTypeScope::createWithAnonymousFunction(); From c70b77869ece928e5d0fc8f03d497de5f66cc407 Mon Sep 17 00:00:00 2001 From: shmax Date: Wed, 1 Apr 2026 14:25:22 -0700 Subject: [PATCH 10/25] fix use function ordering --- src/PhpDoc/TypeNodeResolver.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index 357e7025b76..95562da8f97 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -111,8 +111,8 @@ use PHPStan\Type\ValueOfType; use PHPStan\Type\VoidType; use Traversable; -use function array_key_exists; use function array_filter; +use function array_key_exists; use function array_map; use function array_values; use function count; From ff96cbae0323e71fe4f4a1a0cc5d36a42f5caf56 Mon Sep 17 00:00:00 2001 From: shmax Date: Wed, 1 Apr 2026 15:09:20 -0700 Subject: [PATCH 11/25] fix generic alias resolution: skip alias path when name resolves to a known class --- src/PhpDoc/TypeNodeResolver.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index 95562da8f97..e66f4b40375 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -839,9 +839,15 @@ static function (string $variance): TemplateTypeVariance { return new ErrorType(); } - // Check for a generic type alias (e.g. ProviderRequest) before - // falling through to class-based generic resolution. - $genericTypeAlias = $this->findGenericTypeAlias($typeNode->type->name, $nameScope); + // Check for a generic type alias (e.g. MyList) before falling through to + // class-based generic resolution, but only when the name is not itself a resolvable + // class — in that case the class-based path must win to preserve backward compatibility + // (a type alias like BelongsTo = \Eloquent\BelongsTo should still produce + // the same GenericObjectType that class-based resolution would have given). + $resolvedGenericName = $nameScope->resolveStringName($typeNode->type->name); + $genericTypeAlias = !$this->getReflectionProvider()->hasClass($resolvedGenericName) + ? $this->findGenericTypeAlias($typeNode->type->name, $nameScope) + : null; if ($genericTypeAlias !== null) { $templateNodes = $genericTypeAlias->getTemplateTagValueNodes(); $totalParams = count($templateNodes); From a7d5c464129df76a40cc7a4ebfa30bfe9ce2e98f Mon Sep 17 00:00:00 2001 From: shmax Date: Wed, 1 Apr 2026 15:13:58 -0700 Subject: [PATCH 12/25] remove resolveWithDefaults: bare generic alias usage restores old TemplateType behavior --- src/PhpDoc/TypeNodeResolver.php | 9 -------- src/Type/TypeAlias.php | 37 --------------------------------- 2 files changed, 46 deletions(-) diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index e66f4b40375..ed1b4d008fa 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -495,15 +495,6 @@ private function resolveIdentifierTypeNode(IdentifierTypeNode $typeNode, NameSco } if (!$nameScope->shouldBypassTypeAliases()) { - // Handle a generic alias referenced without type arguments. - // resolveWithDefaults() applies declared defaults so params with defaults are - // substituted, while required params remain as TemplateType placeholders - // (which getRawGenericTypeAliasesUsage() later detects and reports). - $genericAlias = $this->findGenericTypeAlias($typeNode->name, $nameScope); - if ($genericAlias !== null) { - return $genericAlias->resolveWithDefaults($this); - } - $typeAlias = $this->getTypeAliasResolver()->resolveTypeAlias($typeNode->name, $nameScope); if ($typeAlias !== null) { return $typeAlias; diff --git a/src/Type/TypeAlias.php b/src/Type/TypeAlias.php index 9a9469e9d81..08fa796438f 100644 --- a/src/Type/TypeAlias.php +++ b/src/Type/TypeAlias.php @@ -8,7 +8,6 @@ use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; -use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeFactory; use PHPStan\Type\Generic\TemplateTypeHelper; use PHPStan\Type\Generic\TemplateTypeMap; @@ -113,42 +112,6 @@ public function resolveWithArgs(TypeNodeResolver $typeNodeResolver, array $args) ); } - /** - * Resolves the alias body applying default values for template params that declare one. - * Template params without defaults remain as TemplateType placeholders so that callers - * (e.g. MissingTypehintCheck) can detect bare generic alias usage. - */ - public function resolveWithDefaults(TypeNodeResolver $typeNodeResolver): Type - { - $baseType = $this->resolve($typeNodeResolver); - - if (count($this->templateTagValueNodes) === 0) { - return $baseType; - } - - // Collect default values for params that declare one. - $defaultsMap = []; - foreach ($this->templateTagValueNodes as $tvn) { - if ($tvn->default === null) { - continue; - } - $defaultsMap[$tvn->name] = $typeNodeResolver->resolve($tvn->default, $this->nameScope); - } - - if (count($defaultsMap) === 0) { - return $baseType; - } - - // Replace only TemplateType instances scoped to THIS alias that have a declared default. - $aliasName = $this->aliasName; - return TypeTraverser::map($baseType, static function (Type $type, callable $traverse) use ($defaultsMap, $aliasName): Type { - if ($type instanceof TemplateType && $type->getScope()->getTypeAliasName() === $aliasName && isset($defaultsMap[$type->getName()])) { - return $defaultsMap[$type->getName()]; - } - return $traverse($type); - }); - } - /** * Builds a NameScope augmented with TemplateType placeholders for each declared template param, * so the alias body can reference them (e.g. `TFilter` resolves to a TemplateType). From af6079ff3df02ed4e413a0eeca5685996a33adf8 Mon Sep 17 00:00:00 2001 From: shmax Date: Wed, 1 Apr 2026 15:22:50 -0700 Subject: [PATCH 13/25] fix phpstan self-analysis errors: ignore property.notFound, add null guard in getTypeAliasName --- src/PhpDoc/PhpDocNodeResolver.php | 2 +- src/Type/Generic/TemplateTypeScope.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PhpDoc/PhpDocNodeResolver.php b/src/PhpDoc/PhpDocNodeResolver.php index c25d0fa1286..0a5bf85cdf4 100644 --- a/src/PhpDoc/PhpDocNodeResolver.php +++ b/src/PhpDoc/PhpDocNodeResolver.php @@ -522,7 +522,7 @@ public function resolveTypeAliasTags(PhpDocNode $phpDocNode, NameScope $nameScop foreach ($phpDocNode->getTypeAliasTagValues($tagName) as $typeAliasTagValue) { $alias = $typeAliasTagValue->alias; $typeNode = $typeAliasTagValue->type; - $resolved[$alias] = new TypeAliasTag($alias, $typeNode, $nameScope, $typeAliasTagValue->templateTypes ?? []); + $resolved[$alias] = new TypeAliasTag($alias, $typeNode, $nameScope, $typeAliasTagValue->templateTypes ?? []); // @phpstan-ignore property.notFound } } diff --git a/src/Type/Generic/TemplateTypeScope.php b/src/Type/Generic/TemplateTypeScope.php index 04185f2fde4..64c91dea0f8 100644 --- a/src/Type/Generic/TemplateTypeScope.php +++ b/src/Type/Generic/TemplateTypeScope.php @@ -60,7 +60,7 @@ public function isTypeAlias(): bool /** @api */ public function getTypeAliasName(): ?string { - if (!$this->isTypeAlias()) { + if (!$this->isTypeAlias() || $this->functionName === null) { return null; } From a0f9af09d552153877b662d95e611ea84843c151 Mon Sep 17 00:00:00 2001 From: shmax Date: Wed, 1 Apr 2026 15:32:33 -0700 Subject: [PATCH 14/25] remove ignore --- src/PhpDoc/PhpDocNodeResolver.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PhpDoc/PhpDocNodeResolver.php b/src/PhpDoc/PhpDocNodeResolver.php index 0a5bf85cdf4..c25d0fa1286 100644 --- a/src/PhpDoc/PhpDocNodeResolver.php +++ b/src/PhpDoc/PhpDocNodeResolver.php @@ -522,7 +522,7 @@ public function resolveTypeAliasTags(PhpDocNode $phpDocNode, NameScope $nameScop foreach ($phpDocNode->getTypeAliasTagValues($tagName) as $typeAliasTagValue) { $alias = $typeAliasTagValue->alias; $typeNode = $typeAliasTagValue->type; - $resolved[$alias] = new TypeAliasTag($alias, $typeNode, $nameScope, $typeAliasTagValue->templateTypes ?? []); // @phpstan-ignore property.notFound + $resolved[$alias] = new TypeAliasTag($alias, $typeNode, $nameScope, $typeAliasTagValue->templateTypes ?? []); } } From 6778ace011119430536c23cdd7b9d598c7604b30 Mon Sep 17 00:00:00 2001 From: shmax Date: Wed, 1 Apr 2026 15:47:54 -0700 Subject: [PATCH 15/25] add property.notFound baseline entry for PHP < 8.0 (phpdoc-parser lacks templateTypes) --- build/baseline-pre-8.0.neon | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/build/baseline-pre-8.0.neon b/build/baseline-pre-8.0.neon index d170a8d4a94..02aeb1a70e9 100644 --- a/build/baseline-pre-8.0.neon +++ b/build/baseline-pre-8.0.neon @@ -134,6 +134,12 @@ parameters: count: 1 path: ../src/Type/TypeCombinator.php + - + rawMessage: 'Access to an undefined property PHPStan\PhpDocParser\Ast\PhpDoc\TypeAliasTagValueNode::$templateTypes.' + identifier: property.notFound + count: 1 + path: ../src/PhpDoc/PhpDocNodeResolver.php + - rawMessage: Access to property $id of internal class Symfony\Polyfill\Php80\PhpToken from outside its root namespace Symfony. identifier: property.internalClass From 8c95f1faf59f8d204b3dad6298eba97584b2814c Mon Sep 17 00:00:00 2001 From: shmax Date: Wed, 1 Apr 2026 15:56:45 -0700 Subject: [PATCH 16/25] move property.notFound baseline entry to universal phpstan-baseline.neon (affects all PHP versions) --- build/baseline-pre-8.0.neon | 6 ------ phpstan-baseline.neon | 6 ++++++ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/build/baseline-pre-8.0.neon b/build/baseline-pre-8.0.neon index 02aeb1a70e9..d170a8d4a94 100644 --- a/build/baseline-pre-8.0.neon +++ b/build/baseline-pre-8.0.neon @@ -134,12 +134,6 @@ parameters: count: 1 path: ../src/Type/TypeCombinator.php - - - rawMessage: 'Access to an undefined property PHPStan\PhpDocParser\Ast\PhpDoc\TypeAliasTagValueNode::$templateTypes.' - identifier: property.notFound - count: 1 - path: ../src/PhpDoc/PhpDocNodeResolver.php - - rawMessage: Access to property $id of internal class Symfony\Polyfill\Php80\PhpToken from outside its root namespace Symfony. identifier: property.internalClass diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 564df2bc20d..8ded6c398b7 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,5 +1,11 @@ parameters: ignoreErrors: + - + rawMessage: 'Access to an undefined property PHPStan\PhpDocParser\Ast\PhpDoc\TypeAliasTagValueNode::$templateTypes.' + identifier: property.notFound + count: 1 + path: src/PhpDoc/PhpDocNodeResolver.php + - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantStringType is error-prone and deprecated. Use Type::getConstantStrings() instead.' identifier: phpstanApi.instanceofType From 7f5b84dd3e57e5ba33f61d58eeb919cf01885f04 Mon Sep 17 00:00:00 2001 From: shmax Date: Wed, 1 Apr 2026 17:47:39 -0700 Subject: [PATCH 17/25] fix generic alias bare-usage resolution and data-provider parameter signatures --- src/Type/UsefulTypeAliasResolver.php | 23 ++++++++++++++++++- .../Methods/OverridingMethodRuleTest.php | 4 ++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/Type/UsefulTypeAliasResolver.php b/src/Type/UsefulTypeAliasResolver.php index 6577289ff93..50b717d8bc5 100644 --- a/src/Type/UsefulTypeAliasResolver.php +++ b/src/Type/UsefulTypeAliasResolver.php @@ -114,7 +114,28 @@ private function resolveLocalTypeAlias(string $aliasName, NameScope $nameScope): try { $unresolvedAlias = $localTypeAliases[$aliasName]; - $resolvedAliasType = $unresolvedAlias->resolve($this->typeNodeResolver); + + // For a generic alias used bare (no type args provided), check whether every + // declared template param has a default value. When they all do, we can + // resolve immediately to a fully-concrete type by passing an empty args list to + // resolveWithArgs() — which then falls back to each param's declared default. + // When at least one param has no default, we keep the raw TemplateType + // placeholders so that MissingTypehintCheck::getRawGenericTypeAliasesUsage() + // can detect the bare-usage error ("does not specify its types: T"). + if ($unresolvedAlias->isGeneric()) { + $allHaveDefaults = true; + foreach ($unresolvedAlias->getTemplateTagValueNodes() as $tvn) { + if ($tvn->default === null) { + $allHaveDefaults = false; + break; + } + } + $resolvedAliasType = $allHaveDefaults + ? $unresolvedAlias->resolveWithArgs($this->typeNodeResolver, []) + : $unresolvedAlias->resolve($this->typeNodeResolver); + } else { + $resolvedAliasType = $unresolvedAlias->resolve($this->typeNodeResolver); + } } catch (CircularTypeAliasDefinitionException) { $resolvedAliasType = new CircularTypeAliasErrorType(); } diff --git a/tests/PHPStan/Rules/Methods/OverridingMethodRuleTest.php b/tests/PHPStan/Rules/Methods/OverridingMethodRuleTest.php index 55979eeb77d..b0e0acf9996 100644 --- a/tests/PHPStan/Rules/Methods/OverridingMethodRuleTest.php +++ b/tests/PHPStan/Rules/Methods/OverridingMethodRuleTest.php @@ -56,7 +56,7 @@ public static function dataOverridingFinalMethod(): array } #[DataProvider('dataOverridingFinalMethod')] - public function testOverridingFinalMethod(int $phpVersion, string $contravariantMessage): void + public function testOverridingFinalMethod(int $phpVersion, string $contravariantMessage, string $covariantMessage): void { $errors = [ [ @@ -322,7 +322,7 @@ public function testVariadicParameterIsAlwaysOptional(): void } #[DataProvider('dataOverridingFinalMethod')] - public function testBug3403(int $phpVersion): void + public function testBug3403(int $phpVersion, string $contravariantMessage, string $covariantMessage): void { $this->phpVersionId = $phpVersion; $this->analyse([__DIR__ . '/data/bug-3403.php'], []); From 1483f56b61cb7182abe65e62c6593e52494704b6 Mon Sep 17 00:00:00 2001 From: shmax Date: Wed, 1 Apr 2026 22:10:44 -0700 Subject: [PATCH 18/25] update baseline after rebase onto 2.1.x --- phpstan-baseline.neon | 6 ------ 1 file changed, 6 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 8ded6c398b7..564df2bc20d 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,11 +1,5 @@ parameters: ignoreErrors: - - - rawMessage: 'Access to an undefined property PHPStan\PhpDocParser\Ast\PhpDoc\TypeAliasTagValueNode::$templateTypes.' - identifier: property.notFound - count: 1 - path: src/PhpDoc/PhpDocNodeResolver.php - - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantStringType is error-prone and deprecated. Use Type::getConstantStrings() instead.' identifier: phpstanApi.instanceofType From 24e1486d8a2c2982d52d14cfc2b8cf0eeae096a4 Mon Sep 17 00:00:00 2001 From: shmax Date: Wed, 1 Apr 2026 23:02:26 -0700 Subject: [PATCH 19/25] add temp patches --- composer.json | 12 +++++--- patches/PhpDocParser.patch | 39 ++++++++++++++++++++++++ patches/TypeAliasTagValueNode.patch | 47 +++++++++++++++++++++++++++++ src/PhpDoc/PhpDocNodeResolver.php | 2 +- 4 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 patches/PhpDocParser.patch create mode 100644 patches/TypeAliasTagValueNode.patch diff --git a/composer.json b/composer.json index 3e58a44cc38..54567a8539c 100644 --- a/composer.json +++ b/composer.json @@ -128,10 +128,14 @@ "patches/DependencyChecker.patch", "patches/Resolver.patch" ], - "symfony/console": [ - "patches/OutputFormatter.patch" - ] - } + "symfony/console": [ + "patches/OutputFormatter.patch" + ], + "phpstan/phpdoc-parser": [ + "patches/TypeAliasTagValueNode.patch", + "patches/PhpDocParser.patch" + ] + } }, "autoload": { "psr-4": { diff --git a/patches/PhpDocParser.patch b/patches/PhpDocParser.patch new file mode 100644 index 00000000000..167968588ff --- /dev/null +++ b/patches/PhpDocParser.patch @@ -0,0 +1,39 @@ +--- src/Parser/PhpDocParser.php ++++ src/Parser/PhpDocParser.php +@@ -1067,6 +1067,21 @@ + $alias = $tokens->currentTokenValue(); + $tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER); + ++ $templateTypes = []; ++ if ($tokens->tryConsumeTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET)) { ++ do { ++ $startLine = $tokens->currentTokenLine(); ++ $startIndex = $tokens->currentTokenIndex(); ++ $templateTypes[] = $this->enrichWithAttributes( ++ $tokens, ++ $this->typeParser->parseTemplateTagValue($tokens), ++ $startLine, ++ $startIndex, ++ ); ++ } while ($tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA)); ++ $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_ANGLE_BRACKET); ++ } ++ + // support phan-type/psalm-type syntax + $tokens->tryConsumeTokenType(Lexer::TOKEN_EQUAL); + +@@ -1087,12 +1102,13 @@ + } + } + +- return new Ast\PhpDoc\TypeAliasTagValueNode($alias, $type); ++ return new Ast\PhpDoc\TypeAliasTagValueNode($alias, $type, $templateTypes); + } catch (ParserException $e) { + $this->parseOptionalDescription($tokens, false); + return new Ast\PhpDoc\TypeAliasTagValueNode( + $alias, + $this->enrichWithAttributes($tokens, new Ast\Type\InvalidTypeNode($e), $startLine, $startIndex), ++ $templateTypes, + ); + } + } diff --git a/patches/TypeAliasTagValueNode.patch b/patches/TypeAliasTagValueNode.patch new file mode 100644 index 00000000000..4137b44f237 --- /dev/null +++ b/patches/TypeAliasTagValueNode.patch @@ -0,0 +1,47 @@ +--- src/Ast/PhpDoc/TypeAliasTagValueNode.php ++++ src/Ast/PhpDoc/TypeAliasTagValueNode.php +@@ -4,6 +4,7 @@ + + use PHPStan\PhpDocParser\Ast\NodeAttributes; + use PHPStan\PhpDocParser\Ast\Type\TypeNode; ++use function implode; + use function trim; + + class TypeAliasTagValueNode implements PhpDocTagValueNode +@@ -15,15 +16,25 @@ + + public TypeNode $type; + +- public function __construct(string $alias, TypeNode $type) ++ /** @var TemplateTagValueNode[] */ ++ public array $templateTypes; ++ ++ /** ++ * @param TemplateTagValueNode[] $templateTypes ++ */ ++ public function __construct(string $alias, TypeNode $type, array $templateTypes = []) + { + $this->alias = $alias; + $this->type = $type; ++ $this->templateTypes = $templateTypes; + } + + public function __toString(): string + { +- return trim("{$this->alias} {$this->type}"); ++ $templateTypes = $this->templateTypes !== [] ++ ? '<' . implode(', ', $this->templateTypes) . '>' ++ : ''; ++ return trim("{$this->alias}{$templateTypes} {$this->type}"); + } + + /** +@@ -31,7 +42,7 @@ + */ + public static function __set_state(array $properties): self + { +- $instance = new self($properties['alias'], $properties['type']); ++ $instance = new self($properties['alias'], $properties['type'], $properties['templateTypes'] ?? []); + if (isset($properties['attributes'])) { + foreach ($properties['attributes'] as $key => $value) { + $instance->setAttribute($key, $value); diff --git a/src/PhpDoc/PhpDocNodeResolver.php b/src/PhpDoc/PhpDocNodeResolver.php index c25d0fa1286..e9c086bdee2 100644 --- a/src/PhpDoc/PhpDocNodeResolver.php +++ b/src/PhpDoc/PhpDocNodeResolver.php @@ -522,7 +522,7 @@ public function resolveTypeAliasTags(PhpDocNode $phpDocNode, NameScope $nameScop foreach ($phpDocNode->getTypeAliasTagValues($tagName) as $typeAliasTagValue) { $alias = $typeAliasTagValue->alias; $typeNode = $typeAliasTagValue->type; - $resolved[$alias] = new TypeAliasTag($alias, $typeNode, $nameScope, $typeAliasTagValue->templateTypes ?? []); + $resolved[$alias] = new TypeAliasTag($alias, $typeNode, $nameScope, $typeAliasTagValue->templateTypes); } } From 5080e33dc973c90ce1092885edba721db6275718 Mon Sep 17 00:00:00 2001 From: shmax Date: Wed, 1 Apr 2026 23:07:18 -0700 Subject: [PATCH 20/25] update lock hash --- composer.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.lock b/composer.lock index c57b21204c3..5304159a256 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "eef6fd5a65675d77dee63cb2a8c53a2a", + "content-hash": "f7862880740205a816b8a3e2342cd7fe", "packages": [ { "name": "clue/ndjson-react", From c1c8d4da3ed211a2d3482a1d72c48c4138e7ba39 Mon Sep 17 00:00:00 2001 From: shmax Date: Thu, 2 Apr 2026 07:46:16 -0700 Subject: [PATCH 21/25] use LateResolvableType --- src/PhpDoc/TypeNodeResolver.php | 4 +- src/Rules/Classes/LocalTypeAliasesCheck.php | 2 +- src/Rules/MissingTypehintCheck.php | 12 +- src/Type/GenericTypeAliasType.php | 230 ++++++++++++++++++++ src/Type/TypeAlias.php | 39 ++++ src/Type/TypeAliasApplicationType.php | 5 + src/Type/UsefulTypeAliasResolver.php | 24 +- 7 files changed, 292 insertions(+), 24 deletions(-) create mode 100644 src/Type/GenericTypeAliasType.php create mode 100644 src/Type/TypeAliasApplicationType.php diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index ed1b4d008fa..b6fbef69a0e 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -103,6 +103,7 @@ use PHPStan\Type\ThisType; use PHPStan\Type\Type; use PHPStan\Type\TypeAlias; +use PHPStan\Type\GenericTypeAliasType; use PHPStan\Type\TypeAliasResolver; use PHPStan\Type\TypeAliasResolverProvider; use PHPStan\Type\TypeCombinator; @@ -849,7 +850,8 @@ static function (string $variance): TemplateTypeVariance { return new ErrorType(); } - return $genericTypeAlias->resolveWithArgs($this, $genericTypes); + $appType = $genericTypeAlias->createApplicationType($this, $genericTypes); + return $appType->isResolvable() ? $appType->resolve() : $appType; } $mainType = $this->resolveIdentifierTypeNode($typeNode->type, $nameScope); diff --git a/src/Rules/Classes/LocalTypeAliasesCheck.php b/src/Rules/Classes/LocalTypeAliasesCheck.php index 6addd7998a6..86cd06acf5b 100644 --- a/src/Rules/Classes/LocalTypeAliasesCheck.php +++ b/src/Rules/Classes/LocalTypeAliasesCheck.php @@ -223,7 +223,7 @@ public function checkInTraitDefinitionContext(ClassReflection $reflection): arra foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($resolvedType) as [$innerAliasName, $missingParams]) { if ($innerAliasName === $aliasName) { - continue; // alias body contains its own template type placeholders — not a raw usage + continue; // skip self-referential alias bodies (circular aliases are already reported separately) } $errors[] = RuleErrorBuilder::message(sprintf( '%s %s has type alias %s with generic type alias %s but does not specify its types: %s', diff --git a/src/Rules/MissingTypehintCheck.php b/src/Rules/MissingTypehintCheck.php index 6f6e9ff8acb..657d54cb0b0 100644 --- a/src/Rules/MissingTypehintCheck.php +++ b/src/Rules/MissingTypehintCheck.php @@ -22,6 +22,7 @@ use PHPStan\Type\MixedType; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; +use PHPStan\Type\GenericTypeAliasType; use PHPStan\Type\TypeTraverser; use Traversable; use function array_filter; @@ -179,13 +180,13 @@ public function getRawGenericTypeAliasesUsage(Type $type): array /** @var array> $found */ $found = []; TypeTraverser::map($type, static function (Type $type, callable $traverse) use (&$found): Type { - if ($type instanceof TemplateType) { - $aliasName = $type->getScope()->getTypeAliasName(); - if ($aliasName !== null && $type->getDefault() === null) { - $found[$aliasName][] = $type->getName(); + if ($type instanceof GenericTypeAliasType) { + $missing = $type->getMissingRequiredParamNames(); + if ($missing !== []) { + $found[$type->getAliasName()] = $missing; } - return $type; } + return $traverse($type); }); @@ -193,6 +194,7 @@ public function getRawGenericTypeAliasesUsage(Type $type): array foreach ($found as $aliasName => $paramNames) { $result[] = [$aliasName, implode(', ', array_unique($paramNames))]; } + return $result; } diff --git a/src/Type/GenericTypeAliasType.php b/src/Type/GenericTypeAliasType.php new file mode 100644 index 00000000000..5a719182c73 --- /dev/null +++ b/src/Type/GenericTypeAliasType.php @@ -0,0 +1,230 @@ +} where {@code @phpstan-type Filter} is + * declared expands lazily to the alias body with TItem substituted. + * + * Mirrors the role of GenericObjectType for classes: GenericObjectType is a class constructor + * applied to type args; GenericTypeAliasType is a type alias applied to type args. + * + * Implements LateResolvableType so TypeUtils::resolveLateResolvableTypes() expands it at the + * right moment without leaking TemplateType placeholders to the rest of the type system. + */ +final class GenericTypeAliasType implements CompoundType, LateResolvableType +{ + + use LateResolvableTypeTrait; + use NonGeneralizableTypeTrait; + + /** + * @param list $paramNames Ordered parameter names from the alias declaration. + * @param list $args Supplied type arguments (may be shorter than paramNames + * when trailing params are covered by defaults). + * @param list $defaults Per-param declared default type; null when the param has no default. + * @param list $boundFallbacks Per-param bound type used when both arg and default are absent. + */ + public function __construct( + private readonly string $aliasName, + private readonly Type $resolvedBody, + private readonly array $paramNames, + private readonly array $args, + private readonly array $defaults, + private readonly array $boundFallbacks, + ) + { + } + + public function getAliasName(): string + { + return $this->aliasName; + } + + /** + * Returns the names of required params (no declared default) that were not supplied as args. + * A non-empty list means this is a "raw" usage of a generic alias that should be reported. + * + * @return list + */ + public function getMissingRequiredParamNames(): array + { + $missing = []; + foreach ($this->paramNames as $i => $name) { + if (!isset($this->args[$i]) && $this->defaults[$i] === null) { + $missing[] = $name; + } + } + + return $missing; + } + + public function getReferencedClasses(): array + { + $classes = $this->resolvedBody->getReferencedClasses(); + foreach ($this->args as $arg) { + $classes = array_merge($classes, $arg->getReferencedClasses()); + } + + return array_unique($classes); + } + + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + $refs = []; + foreach ($this->args as $arg) { + $refs = array_merge($refs, $arg->getReferencedTemplateTypes($positionVariance)); + } + + return $refs; + } + + public function equals(Type $type): bool + { + if (!$type instanceof self) { + return false; + } + + if ($this->aliasName !== $type->aliasName || count($this->args) !== count($type->args)) { + return false; + } + + foreach ($this->args as $i => $arg) { + if (!$arg->equals($type->args[$i])) { + return false; + } + } + + return true; + } + + public function describe(VerbosityLevel $level): string + { + if ($this->args === []) { + return $this->aliasName; + } + + return sprintf( + '%s<%s>', + $this->aliasName, + implode(', ', array_map(static fn (Type $t) => $t->describe($level), $this->args)), + ); + } + + public function isResolvable(): bool + { + foreach ($this->args as $arg) { + if (TypeUtils::containsTemplateType($arg)) { + return false; + } + } + + foreach ($this->paramNames as $i => $name) { + if (!isset($this->args[$i]) && $this->defaults[$i] === null) { + return false; + } + } + + return true; + } + + protected function getResult(): Type + { + $map = []; + foreach ($this->paramNames as $i => $name) { + $map[$name] = $this->args[$i] ?? $this->defaults[$i] ?? $this->boundFallbacks[$i]; + } + + return TemplateTypeHelper::resolveTemplateTypes( + $this->resolvedBody, + new TemplateTypeMap($map), + TemplateTypeVarianceMap::createEmpty(), + TemplateTypeVariance::createInvariant(), + ); + } + + /** + * @param callable(Type): Type $cb + */ + public function traverse(callable $cb): Type + { + $newArgs = array_map($cb, $this->args); + + foreach ($this->args as $i => $arg) { + if ($arg !== $newArgs[$i]) { + return new self( + $this->aliasName, + $this->resolvedBody, + $this->paramNames, + $newArgs, + $this->defaults, + $this->boundFallbacks, + ); + } + } + + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if (!$right instanceof self) { + return $this; + } + + $newArgs = []; + $changed = false; + foreach ($this->args as $i => $arg) { + $newArg = isset($right->args[$i]) ? $cb($arg, $right->args[$i]) : $arg; + if ($newArg !== $arg) { + $changed = true; + } + + $newArgs[] = $newArg; + } + + if (!$changed) { + return $this; + } + + return new self( + $this->aliasName, + $this->resolvedBody, + $this->paramNames, + $newArgs, + $this->defaults, + $this->boundFallbacks, + ); + } + + public function toPhpDocNode(): TypeNode + { + if ($this->args === []) { + return new IdentifierTypeNode($this->aliasName); + } + + return new GenericTypeNode( + new IdentifierTypeNode($this->aliasName), + array_map(static fn (Type $t) => $t->toPhpDocNode(), $this->args), + ); + } + +} + diff --git a/src/Type/TypeAlias.php b/src/Type/TypeAlias.php index 08fa796438f..bbaccd1dedb 100644 --- a/src/Type/TypeAlias.php +++ b/src/Type/TypeAlias.php @@ -17,6 +17,7 @@ use function array_map; use function array_values; use function count; +use function array_fill; final class TypeAlias { @@ -61,6 +62,11 @@ public function resolve(TypeNodeResolver $typeNodeResolver): Type return $this->resolvedType = $typeNodeResolver->resolve($this->typeNode, $nameScope); } + public function getAliasName(): string + { + return $this->aliasName; + } + /** Whether this alias was declared with type parameters (e.g. @phpstan-type Foo). */ public function isGeneric(): bool { @@ -75,6 +81,39 @@ public function getTemplateTagValueNodes(): array return $this->templateTagValueNodes; } + /** + * Creates a GenericTypeAliasType for this alias with the given type arguments. + * + * @param list $args Concrete or partially-resolved type arguments in parameter order. + */ + public function createApplicationType(TypeNodeResolver $typeNodeResolver, array $args): GenericTypeAliasType + { + $resolvedBody = $this->resolve($typeNodeResolver); + + $paramNames = []; + $defaults = []; + $boundFallbacks = []; + + foreach (array_values($this->templateTagValueNodes) as $tvn) { + $paramNames[] = $tvn->name; + $defaults[] = $tvn->default !== null + ? $typeNodeResolver->resolve($tvn->default, $this->nameScope) + : null; + $boundFallbacks[] = $tvn->bound !== null + ? $typeNodeResolver->resolve($tvn->bound, $this->nameScope) + : new MixedType(true); + } + + return new GenericTypeAliasType( + $this->aliasName, + $resolvedBody, + $paramNames, + $args, + $defaults, + $boundFallbacks, + ); + } + /** * Resolves the alias body substituting concrete $args for each declared template parameter. * diff --git a/src/Type/TypeAliasApplicationType.php b/src/Type/TypeAliasApplicationType.php new file mode 100644 index 00000000000..cdd5021aa87 --- /dev/null +++ b/src/Type/TypeAliasApplicationType.php @@ -0,0 +1,5 @@ +isGeneric()) { - $allHaveDefaults = true; - foreach ($unresolvedAlias->getTemplateTagValueNodes() as $tvn) { - if ($tvn->default === null) { - $allHaveDefaults = false; - break; - } - } - $resolvedAliasType = $allHaveDefaults - ? $unresolvedAlias->resolveWithArgs($this->typeNodeResolver, []) - : $unresolvedAlias->resolve($this->typeNodeResolver); + $appType = $unresolvedAlias->createApplicationType($this->typeNodeResolver, []); + $resolvedAliasType = $appType->isResolvable() ? $appType->resolve() : $appType; } else { $resolvedAliasType = $unresolvedAlias->resolve($this->typeNodeResolver); } From a1f0535ddd0f51368c1806604165eecbd75ad91c Mon Sep 17 00:00:00 2001 From: shmax Date: Thu, 2 Apr 2026 07:49:32 -0700 Subject: [PATCH 22/25] remove --- src/Type/TypeAliasApplicationType.php | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 src/Type/TypeAliasApplicationType.php diff --git a/src/Type/TypeAliasApplicationType.php b/src/Type/TypeAliasApplicationType.php deleted file mode 100644 index cdd5021aa87..00000000000 --- a/src/Type/TypeAliasApplicationType.php +++ /dev/null @@ -1,5 +0,0 @@ - Date: Thu, 2 Apr 2026 07:54:56 -0700 Subject: [PATCH 23/25] lint --- src/PhpDoc/TypeNodeResolver.php | 1 - src/Rules/MissingTypehintCheck.php | 2 +- src/Type/GenericTypeAliasType.php | 10 ++++++---- src/Type/TypeAlias.php | 1 - 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index b6fbef69a0e..c6b544a75fe 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -103,7 +103,6 @@ use PHPStan\Type\ThisType; use PHPStan\Type\Type; use PHPStan\Type\TypeAlias; -use PHPStan\Type\GenericTypeAliasType; use PHPStan\Type\TypeAliasResolver; use PHPStan\Type\TypeAliasResolverProvider; use PHPStan\Type\TypeCombinator; diff --git a/src/Rules/MissingTypehintCheck.php b/src/Rules/MissingTypehintCheck.php index 657d54cb0b0..bc110a31a9e 100644 --- a/src/Rules/MissingTypehintCheck.php +++ b/src/Rules/MissingTypehintCheck.php @@ -18,11 +18,11 @@ use PHPStan\Type\Generic\GenericStaticType; use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeHelper; +use PHPStan\Type\GenericTypeAliasType; use PHPStan\Type\IntersectionType; use PHPStan\Type\MixedType; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; -use PHPStan\Type\GenericTypeAliasType; use PHPStan\Type\TypeTraverser; use Traversable; use function array_filter; diff --git a/src/Type/GenericTypeAliasType.php b/src/Type/GenericTypeAliasType.php index 5a719182c73..4e785cd70ec 100644 --- a/src/Type/GenericTypeAliasType.php +++ b/src/Type/GenericTypeAliasType.php @@ -11,6 +11,7 @@ use PHPStan\Type\Generic\TemplateTypeVarianceMap; use PHPStan\Type\Traits\LateResolvableTypeTrait; use PHPStan\Type\Traits\NonGeneralizableTypeTrait; +use function array_keys; use function array_map; use function array_merge; use function array_unique; @@ -68,9 +69,11 @@ public function getMissingRequiredParamNames(): array { $missing = []; foreach ($this->paramNames as $i => $name) { - if (!isset($this->args[$i]) && $this->defaults[$i] === null) { - $missing[] = $name; + if (isset($this->args[$i]) || $this->defaults[$i] !== null) { + continue; } + + $missing[] = $name; } return $missing; @@ -136,7 +139,7 @@ public function isResolvable(): bool } } - foreach ($this->paramNames as $i => $name) { + foreach (array_keys($this->paramNames) as $i) { if (!isset($this->args[$i]) && $this->defaults[$i] === null) { return false; } @@ -227,4 +230,3 @@ public function toPhpDocNode(): TypeNode } } - diff --git a/src/Type/TypeAlias.php b/src/Type/TypeAlias.php index bbaccd1dedb..b32161ae1f6 100644 --- a/src/Type/TypeAlias.php +++ b/src/Type/TypeAlias.php @@ -17,7 +17,6 @@ use function array_map; use function array_values; use function count; -use function array_fill; final class TypeAlias { From 4b16984116b8d0f01d9eb2e11cbef03d617f6d04 Mon Sep 17 00:00:00 2001 From: shmax Date: Thu, 2 Apr 2026 08:05:59 -0700 Subject: [PATCH 24/25] fix array vs list error --- src/Type/GenericTypeAliasType.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Type/GenericTypeAliasType.php b/src/Type/GenericTypeAliasType.php index 4e785cd70ec..f1913d4b3ef 100644 --- a/src/Type/GenericTypeAliasType.php +++ b/src/Type/GenericTypeAliasType.php @@ -14,7 +14,6 @@ use function array_keys; use function array_map; use function array_merge; -use function array_unique; use function count; use function implode; use function sprintf; @@ -86,7 +85,7 @@ public function getReferencedClasses(): array $classes = array_merge($classes, $arg->getReferencedClasses()); } - return array_unique($classes); + return $classes; } public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array From 928ab12c34ecf0dd94017a793eddfade5a48d77e Mon Sep 17 00:00:00 2001 From: shmax Date: Thu, 2 Apr 2026 08:10:44 -0700 Subject: [PATCH 25/25] remove dead code --- src/Type/TypeAlias.php | 44 ------------------------------------------ 1 file changed, 44 deletions(-) diff --git a/src/Type/TypeAlias.php b/src/Type/TypeAlias.php index b32161ae1f6..8a0f5d8ede8 100644 --- a/src/Type/TypeAlias.php +++ b/src/Type/TypeAlias.php @@ -9,11 +9,9 @@ use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\Type\Generic\TemplateTypeFactory; -use PHPStan\Type\Generic\TemplateTypeHelper; use PHPStan\Type\Generic\TemplateTypeMap; use PHPStan\Type\Generic\TemplateTypeScope; use PHPStan\Type\Generic\TemplateTypeVariance; -use PHPStan\Type\Generic\TemplateTypeVarianceMap; use function array_map; use function array_values; use function count; @@ -61,11 +59,6 @@ public function resolve(TypeNodeResolver $typeNodeResolver): Type return $this->resolvedType = $typeNodeResolver->resolve($this->typeNode, $nameScope); } - public function getAliasName(): string - { - return $this->aliasName; - } - /** Whether this alias was declared with type parameters (e.g. @phpstan-type Foo). */ public function isGeneric(): bool { @@ -113,43 +106,6 @@ public function createApplicationType(TypeNodeResolver $typeNodeResolver, array ); } - /** - * Resolves the alias body substituting concrete $args for each declared template parameter. - * - * @param Type[] $args Concrete types in the same order as the declared template params. - */ - public function resolveWithArgs(TypeNodeResolver $typeNodeResolver, array $args): Type - { - $resolvedType = $this->resolve($typeNodeResolver); - - if (count($this->templateTagValueNodes) === 0) { - return $resolvedType; - } - - // Map each template param name to the supplied arg (or its declared default / upper bound). - $templateTypeMapTypes = []; - foreach (array_values($this->templateTagValueNodes) as $i => $templateTagValueNode) { - if (isset($args[$i])) { - $templateTypeMapTypes[$templateTagValueNode->name] = $args[$i]; - } else { - $bound = $templateTagValueNode->bound !== null - ? $typeNodeResolver->resolve($templateTagValueNode->bound, $this->nameScope) - : new MixedType(true); - $default = $templateTagValueNode->default !== null - ? $typeNodeResolver->resolve($templateTagValueNode->default, $this->nameScope) - : null; - $templateTypeMapTypes[$templateTagValueNode->name] = $default ?? $bound; - } - } - - return TemplateTypeHelper::resolveTemplateTypes( - $resolvedType, - new TemplateTypeMap($templateTypeMapTypes), - TemplateTypeVarianceMap::createEmpty(), - TemplateTypeVariance::createInvariant(), - ); - } - /** * Builds a NameScope augmented with TemplateType placeholders for each declared template param, * so the alias body can reference them (e.g. `TFilter` resolves to a TemplateType).