From e8528b0697dae2a7ea0f77faace88e8e6368490a Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Sat, 26 Sep 2026 23:11:15 +0200 Subject: [PATCH] Generalize an inferred template type by what a conditional branch knows about it In the else branch of `(TNewKey is \UnitEnum ? array-key : TNewKey)` with TNewKey of array-key|\UnitEnum, the branch knows TNewKey is an array-key. Since the branches hold NarrowedSubjectType references, the resolver traversed into them and generalized the inferred type against the subject's own bound, which is not scalar, so a literal key such as 'foo' became string. 2.2.14 kept 'foo'. The resolver now resolves the subject inside a NarrowedSubjectType without generalizing it, and generalizes the narrowed result against the reference, whose bound is the narrowed one. A template type in the target stays generalized, as the condition sees it: otherwise `(T is U ? T : int)` narrowed 'foo' & 'bar' to never. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ResolvedFunctionVariantWithOriginal.php | 106 +++++++++++------- .../narrowed-subject-inferred-literal.php | 90 +++++++++++++++ 2 files changed, 157 insertions(+), 39 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/narrowed-subject-inferred-literal.php diff --git a/src/Reflection/ResolvedFunctionVariantWithOriginal.php b/src/Reflection/ResolvedFunctionVariantWithOriginal.php index 41674655695..ff1a709650e 100644 --- a/src/Reflection/ResolvedFunctionVariantWithOriginal.php +++ b/src/Reflection/ResolvedFunctionVariantWithOriginal.php @@ -10,6 +10,7 @@ use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeHelper; use PHPStan\Type\Generic\TemplateTypeMap; +use PHPStan\Type\Generic\TemplateTypeReference; use PHPStan\Type\Generic\TemplateTypeVariance; use PHPStan\Type\Generic\TemplateTypeVarianceMap; use PHPStan\Type\NarrowedSubjectType; @@ -230,19 +231,19 @@ private function resolveResolvableTemplateTypes(Type $type, TemplateTypeVariance { $references = $type->getReferencedTemplateTypes($positionVariance); - $objectCb = function (Type $type, callable $traverse) use ($references): Type { - if ( - $type instanceof TemplateType - && !$type instanceof NarrowedSubjectType - && !$type->isArgument() - && $type->getScope()->getFunctionName() !== null - ) { + $objectCb = fn (Type $type, callable $traverse): Type => $this->resolveTemplateTypeInGenericType($type, $traverse, $references, null); + + return TypeTraverser::map($type, function (Type $type, callable $traverse) use ($references, $objectCb): Type { + if ($type instanceof GenericObjectType || $type instanceof GenericStaticType) { + return TypeTraverser::map($type, $objectCb); + } + + if ($type instanceof TemplateType && !$type instanceof NarrowedSubjectType && !$type->isArgument()) { $newType = $this->resolvedTemplateTypeMap->getType($type->getName()); if ($newType === null || $newType instanceof ErrorType) { return $traverse($type); } - $newType = TemplateTypeHelper::generalizeInferredTemplateType($type, $newType); $variance = TemplateTypeVariance::createInvariant(); foreach ($references as $reference) { // this uses identity to distinguish between different occurrences of the same template type @@ -270,47 +271,74 @@ private function resolveResolvableTemplateTypes(Type $type, TemplateTypeVariance } return $traverse($type); - }; + }); + } - return TypeTraverser::map($type, function (Type $type, callable $traverse) use ($references, $objectCb): Type { - if ($type instanceof GenericObjectType || $type instanceof GenericStaticType) { - return TypeTraverser::map($type, $objectCb); - } + /** + * @param callable(Type): Type $traverse + * @param list $references + * @param string|null $keepInferredName a template type whose inferred type is not generalized here + */ + private function resolveTemplateTypeInGenericType(Type $type, callable $traverse, array $references, ?string $keepInferredName): Type + { + if ( + $type instanceof NarrowedSubjectType + && $keepInferredName === null + && !$type->isArgument() + && $type->getScope()->getFunctionName() !== null + ) { + // generalize what the branch knows about the subject, not the subject: in the + // else branch of `(T is \UnitEnum ? array-key : T)` with T of array-key|\UnitEnum, + // T is a scalar and an inferred 'foo' stays 'foo'. Only the subject is taken as + // inferred - a template type in the target is generalized as the condition sees it. + $subjectName = $type->getName(); + $resolved = TypeTraverser::map($type, fn (Type $type, callable $traverse): Type => $this->resolveTemplateTypeInGenericType($type, $traverse, $references, $subjectName)); + + return TemplateTypeHelper::generalizeInferredTemplateType($type, $resolved); + } - if ($type instanceof TemplateType && !$type instanceof NarrowedSubjectType && !$type->isArgument()) { - $newType = $this->resolvedTemplateTypeMap->getType($type->getName()); - if ($newType === null || $newType instanceof ErrorType) { - return $traverse($type); - } + if ( + $type instanceof TemplateType + && !$type instanceof NarrowedSubjectType + && !$type->isArgument() + && $type->getScope()->getFunctionName() !== null + ) { + $newType = $this->resolvedTemplateTypeMap->getType($type->getName()); + if ($newType === null || $newType instanceof ErrorType) { + return $traverse($type); + } - $variance = TemplateTypeVariance::createInvariant(); - foreach ($references as $reference) { - // this uses identity to distinguish between different occurrences of the same template type - // see https://github.com/phpstan/phpstan-src/pull/2485#discussion_r1328555397 for details - if ($reference->getType() === $type) { - $variance = $reference->getPositionVariance(); - break; - } - } + if ($type->getName() !== $keepInferredName) { + $newType = TemplateTypeHelper::generalizeInferredTemplateType($type, $newType); + } - $callSiteVariance = $this->callSiteVarianceMap->getVariance($type->getName()); - if ($callSiteVariance === null || $callSiteVariance->invariant()) { - return $newType; + $variance = TemplateTypeVariance::createInvariant(); + foreach ($references as $reference) { + // this uses identity to distinguish between different occurrences of the same template type + // see https://github.com/phpstan/phpstan-src/pull/2485#discussion_r1328555397 for details + if ($reference->getType() === $type) { + $variance = $reference->getPositionVariance(); + break; } + } - if (!$callSiteVariance->covariant() && $variance->covariant()) { - return $traverse($type->getBound()); - } + $callSiteVariance = $this->callSiteVarianceMap->getVariance($type->getName()); + if ($callSiteVariance === null || $callSiteVariance->invariant()) { + return $newType; + } - if (!$callSiteVariance->contravariant() && $variance->contravariant()) { - return new NonAcceptingNeverType(); - } + if (!$callSiteVariance->covariant() && $variance->covariant()) { + return $traverse($type->getBound()); + } - return $newType; + if (!$callSiteVariance->contravariant() && $variance->contravariant()) { + return new NonAcceptingNeverType(); } - return $traverse($type); - }); + return $newType; + } + + return $traverse($type); } /** diff --git a/tests/PHPStan/Analyser/nsrt/narrowed-subject-inferred-literal.php b/tests/PHPStan/Analyser/nsrt/narrowed-subject-inferred-literal.php new file mode 100644 index 00000000000..d11ff24c2a7 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/narrowed-subject-inferred-literal.php @@ -0,0 +1,90 @@ += 8.1 + +declare(strict_types = 1); + +namespace NarrowedSubjectInferredLiteral; + +use function PHPStan\Testing\assertType; + +class User +{ + +} + +enum Digit: int +{ + + case One = 1; + +} + +/** + * @template TKey of array-key + * @template TValue + */ +class Collection +{ + + /** + * @template TNewKey of array-key|\UnitEnum + * + * @param (callable(TValue, TKey): TNewKey)|array|string $keyBy + * @return static<($keyBy is (array|string) ? array-key : (TNewKey is \UnitEnum ? array-key : TNewKey)), TValue> + */ + public function keyBy($keyBy) + { + throw new \Exception(); + } + + /** + * @template TNewKey of array-key|\UnitEnum + * + * @param callable(TValue, TKey): TNewKey $keyBy + * @return static<(TNewKey is \UnitEnum ? array-key : TNewKey), TValue> + */ + public function keyByCallback($keyBy) + { + throw new \Exception(); + } + +} + +/** + * @template T of array-key + * @template U of array-key + * @param T $a + * @param U $b + * @return Collection<(T is U ? T : int), int> + */ +function templateTarget($a, $b) +{ + throw new \Exception(); +} + +/** + * @template T of array-key + * @template U of array-key + * @param T $a + * @param U $b + * @return Collection<(T is not U ? int : T), int> + */ +function negatedTemplateTarget($a, $b) +{ + throw new \Exception(); +} + +/** @param Collection $collection */ +function test(Collection $collection): void +{ + assertType("NarrowedSubjectInferredLiteral\\Collection<'foo', NarrowedSubjectInferredLiteral\\User>", $collection->keyBy(fn ($user) => 'foo')); + assertType('NarrowedSubjectInferredLiteral\\Collection<0, NarrowedSubjectInferredLiteral\\User>', $collection->keyBy(static fn ($user): int => 0)); + assertType('NarrowedSubjectInferredLiteral\\Collection<(int|string), NarrowedSubjectInferredLiteral\\User>', $collection->keyBy('name')); + assertType('NarrowedSubjectInferredLiteral\\Collection<(int|string), NarrowedSubjectInferredLiteral\\User>', $collection->keyBy(static fn ($user) => Digit::One)); + + assertType("NarrowedSubjectInferredLiteral\\Collection<'foo', NarrowedSubjectInferredLiteral\\User>", $collection->keyByCallback(fn ($user) => 'foo')); + assertType('NarrowedSubjectInferredLiteral\\Collection<(int|string), NarrowedSubjectInferredLiteral\\User>', $collection->keyByCallback(static fn ($user) => Digit::One)); + + // the condition compares the generalized T and U, the branch keeps the inferred T + assertType("NarrowedSubjectInferredLiteral\\Collection<'foo', int>", templateTarget('foo', 'bar')); + assertType("NarrowedSubjectInferredLiteral\\Collection<'foo', int>", negatedTemplateTarget('foo', 'bar')); +}