From 851eece45524525f2924b017e9d2c897ceebeda2 Mon Sep 17 00:00:00 2001 From: ondrejmirtes <104888+ondrejmirtes@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:45:06 +0000 Subject: [PATCH] Use lower bound types for contravariant template positions in `GenericObjectType::inferTemplateTypes` - In `GenericObjectType::inferTemplateTypes()`, inferred types from contravariant template positions are now converted to lower bound types via `convertToLowerBoundTypes()`. This mirrors how `ClosureType` handles parameter types (contravariant positions). - The effective variance is determined using the same logic as `getReferencedTemplateTypes()`: explicit call-site variance takes precedence, falling back to the declared template variance. - This fixes template inference when a child interface with an invariant template extends a parent with a contravariant template. Previously, types from contravariant positions were treated as upper bounds (unioned), causing the inferred type to widen incorrectly. Now they are treated as lower bounds (intersected), preserving the narrower type from covariant positions. - Also fixes the same issue for direct use of contravariant generic types in non-variadic parameters, methods, and static methods. --- src/Type/Generic/GenericObjectType.php | 19 ++- tests/PHPStan/Analyser/nsrt/bug-12444.php | 92 ++++++++++++ tests/PHPStan/Analyser/nsrt/bug-12444b.php | 158 +++++++++++++++++++++ 3 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-12444.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-12444b.php diff --git a/src/Type/Generic/GenericObjectType.php b/src/Type/Generic/GenericObjectType.php index 7ee37d6d41..d3f729a1e5 100644 --- a/src/Type/Generic/GenericObjectType.php +++ b/src/Type/Generic/GenericObjectType.php @@ -296,9 +296,26 @@ public function inferTemplateTypes(Type $receivedType): TemplateTypeMap $otherTypes = $ancestorClassReflection->typeMapToList($ancestorClassReflection->getActiveTemplateTypeMap()); $typeMap = TemplateTypeMap::createEmpty(); + $classReflection = $this->getClassReflection(); + $typeList = []; + if ($classReflection !== null) { + $typeList = $classReflection->typeMapToList($classReflection->getTemplateTypeMap()); + } + foreach ($this->getTypes() as $i => $type) { $other = $otherTypes[$i] ?? new ErrorType(); - $typeMap = $typeMap->union($type->inferTemplateTypes($other)); + $map = $type->inferTemplateTypes($other); + + $effectiveVariance = $this->variances[$i] ?? TemplateTypeVariance::createInvariant(); + if ($effectiveVariance->invariant() && isset($typeList[$i]) && $typeList[$i] instanceof TemplateType) { + $effectiveVariance = $typeList[$i]->getVariance(); + } + + if ($effectiveVariance->contravariant()) { + $map = $map->convertToLowerBoundTypes(); + } + + $typeMap = $typeMap->union($map); } return $typeMap; diff --git a/tests/PHPStan/Analyser/nsrt/bug-12444.php b/tests/PHPStan/Analyser/nsrt/bug-12444.php new file mode 100644 index 0000000000..5f447e8d8e --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-12444.php @@ -0,0 +1,92 @@ + $class + * @return Covariant + */ +function covariant(string $class): Covariant +{ + throw new \Exception(); +} + +/** + * @template-contravariant T + */ +interface Contravariant {} + +/** + * @template T of object + * @param class-string $class + * @return Contravariant + */ +function contravariant(string $class): Contravariant +{ + throw new \Exception(); +} + +/** + * @template T + * @extends Covariant + * @extends Contravariant + */ +interface Invariant extends Covariant, Contravariant {} + +/** + * @template T of object + * @param class-string $class + * @return Invariant + */ +function invariant(string $class): Invariant +{ + throw new \Exception(); +} + +/** + * @template T + * @param T $value + * @param Covariant ...$covariants + * @return T + */ +function testCovariant(mixed $value, Covariant ...$covariants): mixed +{ + return $value; +} + +/** + * @template T + * @param T $value + * @param Contravariant ...$contravariants + * @return T + */ +function testContravariant(mixed $value, Contravariant ...$contravariants): mixed +{ + return $value; +} + +// Contravariant with direct Contravariant args +$r3 = testContravariant( + new \RuntimeException(), + contravariant(\Throwable::class), + contravariant(\Exception::class), +); +assertType('RuntimeException', $r3); + +// Contravariant with Invariant args (extending Contravariant) - this is the reported bug +$r4 = testContravariant( + new \RuntimeException(), + invariant(\Throwable::class), + invariant(\Exception::class), +); +assertType('RuntimeException', $r4); diff --git a/tests/PHPStan/Analyser/nsrt/bug-12444b.php b/tests/PHPStan/Analyser/nsrt/bug-12444b.php new file mode 100644 index 0000000000..fef26bf5d1 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-12444b.php @@ -0,0 +1,158 @@ + + */ +interface Inv extends Contra {} + +/** + * @template T of object + * @param class-string $class + * @return Contra + */ +function contra(string $class): Contra +{ + throw new \Exception(); +} + +/** + * @template T of object + * @param class-string $class + * @return Inv + */ +function inv(string $class): Inv +{ + throw new \Exception(); +} + +// Non-variadic: two separate contravariant params +/** + * @template T + * @param T $value + * @param Contra $a + * @param Contra $b + * @return T + */ +function testTwoParams(mixed $value, Contra $a, Contra $b): mixed +{ + return $value; +} + +// Non-variadic with direct Contra +$r1 = testTwoParams( + new \RuntimeException(), + contra(\Throwable::class), + contra(\Exception::class), +); +assertType('RuntimeException', $r1); + +// Non-variadic with Inv (extending Contra) +$r2 = testTwoParams( + new \RuntimeException(), + inv(\Throwable::class), + inv(\Exception::class), +); +assertType('RuntimeException', $r2); + +// Mixed variance: function with both covariant and contravariant template params +/** + * @template-covariant Out + * @template-contravariant In + */ +interface Func +{ +} + +/** + * @template Out + * @template In + * @extends Func + */ +interface InvFunc extends Func {} + +/** + * @template T + * @param Func $fn + * @param T $value + * @return T + */ +function applyFunc(Func $fn, mixed $value): mixed +{ + return $value; +} + +/** + * @param Func<\Exception, \Throwable> $fn + */ +function testMixedVariance(Func $fn): void +{ + $r = applyFunc($fn, new \RuntimeException()); + assertType('Exception', $r); +} + +/** + * @param InvFunc<\Exception, \Throwable> $fn + */ +function testMixedVarianceWithInv(InvFunc $fn): void +{ + $r = applyFunc($fn, new \RuntimeException()); + assertType('Exception', $r); +} + +// Method on a class (vs function) +class Container +{ + /** + * @template T + * @param T $value + * @param Contra ...$contras + * @return T + */ + public function test(mixed $value, Contra ...$contras): mixed + { + return $value; + } + + /** + * @template T + * @param T $value + * @param Contra ...$contras + * @return T + */ + public static function testStatic(mixed $value, Contra ...$contras): mixed + { + return $value; + } +} + +function testMethod(): void +{ + $c = new Container(); + // Method with Inv args + $r = $c->test( + new \RuntimeException(), + inv(\Throwable::class), + inv(\Exception::class), + ); + assertType('RuntimeException', $r); + + // Static method with Inv args + $r2 = Container::testStatic( + new \RuntimeException(), + inv(\Throwable::class), + inv(\Exception::class), + ); + assertType('RuntimeException', $r2); +}