From e5fb80d8946a0a3d2a3e0c5082bc6177c9e02be7 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:21:52 +0000 Subject: [PATCH 1/2] Do not treat `mixed` type arguments as compatible both ways when both generic types are parameterized - `TemplateTypeVariance::isValidVariance()` gained a `$strict` flag. Without it the behaviour is unchanged: `mixed` and `BenevolentUnionType` type arguments answer "yes" in both directions, which is what keeps unparameterized class names (whose type arguments are resolved to the template bounds) compatible with parameterized ones. - With `$strict` the gradual-typing shortcuts are skipped, so covariance and contravariance are evaluated normally, and invariance only keeps `Foo` a supertype of `Foo` while the opposite direction becomes `maybe`. - `GenericObjectType::isSuperTypeOfInternal()` turns the strict mode on outside of the accepts context when the compared type is itself a `GenericObjectType`, i.e. when both sides carry explicit type arguments. - This makes `isSuperTypeOf()` antisymmetric again, so `TypeCombinator::union()` and `TypeCombinator::intersect()` no longer produce a different result depending on the order of the operands. - Analogous cases probed and fixed by the same change: `@template-covariant`, `@template-contravariant`, generic interfaces reached through `@implements`/`@extends`, `class-string>`, and intersections. Array/iterable/callable/object-shape type arguments and unparameterized subclasses (`None extends Option`) were probed and were already order-independent. - Extended `TemplateTypeVarianceTest` to assert both the default and the strict result in both directions. --- phpstan-baseline.neon | 2 +- src/Type/Generic/GenericObjectType.php | 13 +- src/Type/Generic/TemplateType.php | 2 +- src/Type/Generic/TemplateTypeTrait.php | 4 +- src/Type/Generic/TemplateTypeVariance.php | 58 ++++++-- tests/PHPStan/Analyser/nsrt/bug-15198.php | 135 ++++++++++++++++++ .../Type/Generic/TemplateTypeVarianceTest.php | 83 ++++------- 7 files changed, 224 insertions(+), 73 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15198.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 18a7e5c81ac..be80ac941e4 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1113,7 +1113,7 @@ parameters: - rawMessage: Doing instanceof PHPStan\Type\Generic\GenericObjectType is error-prone and deprecated. identifier: phpstanApi.instanceofType - count: 3 + count: 4 path: src/Type/Generic/GenericObjectType.php - diff --git a/src/Type/Generic/GenericObjectType.php b/src/Type/Generic/GenericObjectType.php index dd4919515ce..1373c268ee5 100644 --- a/src/Type/Generic/GenericObjectType.php +++ b/src/Type/Generic/GenericObjectType.php @@ -172,6 +172,15 @@ private function isSuperTypeOfInternal(Type $type, bool $acceptsContext): IsSupe return $nakedSuperTypeOf; } + // Type arguments of a class name written without them are resolved to the + // template bounds, so a `mixed` there means "not parameterized" and stays + // compatible with any other type argument. When the other side is explicitly + // parameterized, `mixed` is a type argument like any other and the variance + // has to be evaluated strictly - otherwise `Foo` and `Foo` would + // be supertypes of each other and TypeCombinator::union() would discard one + // of them depending on their order. + $strictVariance = !$acceptsContext && $type instanceof self; + $typeList = $classReflection->typeMapToList($classReflection->getTemplateTypeMap()); $results = []; foreach ($typeList as $i => $templateType) { @@ -191,9 +200,9 @@ private function isSuperTypeOfInternal(Type $type, bool $acceptsContext): IsSupe $thisVariance = $this->variances[$i] ?? TemplateTypeVariance::createInvariant(); $ancestorVariance = $ancestor->variances[$i] ?? TemplateTypeVariance::createInvariant(); if (!$thisVariance->invariant()) { - $results[] = $thisVariance->isValidVariance($templateType, $this->types[$i], $ancestor->types[$i]); + $results[] = $thisVariance->isValidVariance($templateType, $this->types[$i], $ancestor->types[$i], $strictVariance); } else { - $results[] = $templateType->isValidVariance($this->types[$i], $ancestor->types[$i]); + $results[] = $templateType->isValidVariance($this->types[$i], $ancestor->types[$i], $strictVariance); } $results[] = IsSuperTypeOfResult::createFromBoolean($thisVariance->validPosition($ancestorVariance)); diff --git a/src/Type/Generic/TemplateType.php b/src/Type/Generic/TemplateType.php index df82c5c08fd..2f7e2d03ee9 100644 --- a/src/Type/Generic/TemplateType.php +++ b/src/Type/Generic/TemplateType.php @@ -26,7 +26,7 @@ public function toArgument(): TemplateType; public function isArgument(): bool; - public function isValidVariance(Type $a, Type $b): IsSuperTypeOfResult; + public function isValidVariance(Type $a, Type $b, bool $strict = false): IsSuperTypeOfResult; public function getVariance(): TemplateTypeVariance; diff --git a/src/Type/Generic/TemplateTypeTrait.php b/src/Type/Generic/TemplateTypeTrait.php index 769067fc904..205de4f63e2 100644 --- a/src/Type/Generic/TemplateTypeTrait.php +++ b/src/Type/Generic/TemplateTypeTrait.php @@ -115,9 +115,9 @@ public function toArgument(): TemplateType ); } - public function isValidVariance(Type $a, Type $b): IsSuperTypeOfResult + public function isValidVariance(Type $a, Type $b, bool $strict = false): IsSuperTypeOfResult { - return $this->variance->isValidVariance($this, $a, $b); + return $this->variance->isValidVariance($this, $a, $b, $strict); } public function subtract(Type $typeToRemove): Type diff --git a/src/Type/Generic/TemplateTypeVariance.php b/src/Type/Generic/TemplateTypeVariance.php index 6bc8d433241..4a44b32865b 100644 --- a/src/Type/Generic/TemplateTypeVariance.php +++ b/src/Type/Generic/TemplateTypeVariance.php @@ -149,30 +149,42 @@ public function compose(self $other): self return $other; } - public function isValidVariance(TemplateType $templateType, Type $a, Type $b): IsSuperTypeOfResult + /** + * By default `mixed` and benevolent unions are compatible with any other type + * argument in both directions - that is how gradual typing works. + * + * With $strict that answer is not given: it makes the relation symmetric + * (`Foo` and `Foo` end up supertypes of each other) and + * TypeCombinator::union() then discards one of them depending on the order the + * types come in. In the strict mode invariance is relaxed just enough to keep + * `Foo` a supertype of `Foo` and not the other way around. + */ + public function isValidVariance(TemplateType $templateType, Type $a, Type $b, bool $strict = false): IsSuperTypeOfResult { if ($b instanceof NeverType) { return IsSuperTypeOfResult::createYes(); } - if ($a instanceof MixedType && !$a instanceof TemplateType) { - return IsSuperTypeOfResult::createYes(); - } - - if ($a instanceof BenevolentUnionType) { - if (!$a->isSuperTypeOf($b)->no()) { + if (!$strict) { + if ($a instanceof MixedType && !$a instanceof TemplateType) { return IsSuperTypeOfResult::createYes(); } - } - if ($b instanceof BenevolentUnionType) { - if (!$b->isSuperTypeOf($a)->no()) { - return IsSuperTypeOfResult::createYes(); + if ($a instanceof BenevolentUnionType) { + if (!$a->isSuperTypeOf($b)->no()) { + return IsSuperTypeOfResult::createYes(); + } } - } - if ($b instanceof MixedType && !$b instanceof TemplateType) { - return IsSuperTypeOfResult::createYes(); + if ($b instanceof BenevolentUnionType) { + if (!$b->isSuperTypeOf($a)->no()) { + return IsSuperTypeOfResult::createYes(); + } + } + + if ($b instanceof MixedType && !$b instanceof TemplateType) { + return IsSuperTypeOfResult::createYes(); + } } if ($this->invariant()) { @@ -183,6 +195,24 @@ public function isValidVariance(TemplateType $templateType, Type $a, Type $b): I return IsSuperTypeOfResult::createYes(); } + if ($strict) { + if ($a instanceof MixedType && !$a instanceof TemplateType) { + return IsSuperTypeOfResult::createYes(); + } + + if ($a instanceof BenevolentUnionType && !$a->isSuperTypeOf($b)->no()) { + return IsSuperTypeOfResult::createYes(); + } + + if ($b instanceof BenevolentUnionType && !$b->isSuperTypeOf($a)->no()) { + return IsSuperTypeOfResult::createMaybe(); + } + + if ($b instanceof MixedType && !$b instanceof TemplateType) { + return IsSuperTypeOfResult::createMaybe(); + } + } + $result = $a->equals($b); $reasons = []; if (!$result) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-15198.php b/tests/PHPStan/Analyser/nsrt/bug-15198.php new file mode 100644 index 00000000000..46d331d6bcd --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15198.php @@ -0,0 +1,135 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug15198; + +use Exception; +use function PHPStan\Testing\assertType; + +/** @template T */ +class Foo +{ + + /** @param T $value */ + public function __construct( + public mixed $value, + ) + { + } + +} + +/** @template T */ +interface FooInterface +{ + +} + +/** @template-covariant T */ +class CovariantFoo +{ + +} + +/** @template-contravariant T */ +class ContravariantFoo +{ + +} + +/** @template T */ +abstract class Option +{ + +} + +/** @extends Option */ +final class None extends Option +{ + +} + +function original(): void +{ + try { + /** @var mixed */ + $bar = 'bar'; + $foo = new Foo($bar); + } catch (Exception $e) { + $foo = new Foo(123); + } + + assertType('Bug15198\Foo', $foo); +} + +function ifElse(bool $c, mixed $bar): void +{ + if ($c) { + $foo = new Foo($bar); + } else { + $foo = new Foo(123); + } + + assertType('Bug15198\Foo', $foo); +} + +function ifElseReversed(bool $c, mixed $bar): void +{ + if ($c) { + $foo = new Foo(123); + } else { + $foo = new Foo($bar); + } + + assertType('Bug15198\Foo', $foo); +} + +/** + * @param Foo|Foo $a + * @param Foo|Foo $b + * @param CovariantFoo|CovariantFoo $c + * @param CovariantFoo|CovariantFoo $d + * @param ContravariantFoo|ContravariantFoo $e + * @param ContravariantFoo|ContravariantFoo $f + * @param FooInterface|FooInterface $g + * @param FooInterface|FooInterface $h + * @param class-string>|class-string> $i + * @param class-string>|class-string> $j + */ +function unions($a, $b, $c, $d, $e, $f, $g, $h, $i, $j): void +{ + assertType('Bug15198\Foo', $a); + assertType('Bug15198\Foo', $b); + assertType('Bug15198\CovariantFoo', $c); + assertType('Bug15198\CovariantFoo', $d); + assertType('Bug15198\ContravariantFoo', $e); + assertType('Bug15198\ContravariantFoo', $f); + assertType('Bug15198\FooInterface', $g); + assertType('Bug15198\FooInterface', $h); + assertType('class-string>', $i); + assertType('class-string>', $j); +} + +/** + * @param Foo&Foo $a + * @param Foo&Foo $b + */ +function intersections($a, $b): void +{ + assertType('Bug15198\Foo', $a); + assertType('Bug15198\Foo', $b); +} + +/** + * A class name written without type arguments has them resolved to the template + * bounds, so `None` stays compatible with `Option`. + * + * @param None|Option $a + * @param Option|None $b + */ +function unparameterizedSubclass($a, $b): void +{ + assertType('Bug15198\Option', $a); + assertType('Bug15198\Option', $b); +} diff --git a/tests/PHPStan/Type/Generic/TemplateTypeVarianceTest.php b/tests/PHPStan/Type/Generic/TemplateTypeVarianceTest.php index e65f1a963ed..e2b857a268e 100644 --- a/tests/PHPStan/Type/Generic/TemplateTypeVarianceTest.php +++ b/tests/PHPStan/Type/Generic/TemplateTypeVarianceTest.php @@ -6,6 +6,7 @@ use PHPStan\TrinaryLogic; use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\IntegerType; +use PHPStan\Type\MixedType; use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\UnionType; @@ -18,63 +19,27 @@ class TemplateTypeVarianceTest extends PHPStanTestCase public static function dataIsValidVariance(): iterable { - foreach ([TemplateTypeVariance::createInvariant(), TemplateTypeVariance::createCovariant()] as $variance) { - yield [ - $variance, - new BenevolentUnionType([new IntegerType(), new StringType()]), - new BenevolentUnionType([new IntegerType(), new StringType()]), - TrinaryLogic::createYes(), - TrinaryLogic::createYes(), - ]; - - yield [ - $variance, - new IntegerType(), - new BenevolentUnionType([new IntegerType(), new StringType()]), - TrinaryLogic::createYes(), - TrinaryLogic::createYes(), - ]; - - yield [ - $variance, - new BenevolentUnionType([new IntegerType(), new StringType()]), - new IntegerType(), - TrinaryLogic::createYes(), - TrinaryLogic::createYes(), - ]; + $benevolent = static fn (): BenevolentUnionType => new BenevolentUnionType([new IntegerType(), new StringType()]); + $yes = TrinaryLogic::createYes(); + $maybe = TrinaryLogic::createMaybe(); - yield [ - $variance, - new StringType(), - new BenevolentUnionType([new IntegerType(), new StringType()]), - TrinaryLogic::createYes(), - TrinaryLogic::createYes(), - ]; + foreach ([TemplateTypeVariance::createInvariant(), TemplateTypeVariance::createCovariant()] as $variance) { + yield [$variance, $benevolent(), $benevolent(), $yes, $yes, $yes, $yes]; + yield [$variance, new IntegerType(), $benevolent(), $yes, $yes, $maybe, $yes]; + yield [$variance, $benevolent(), new IntegerType(), $yes, $yes, $yes, $maybe]; + yield [$variance, new StringType(), $benevolent(), $yes, $yes, $maybe, $yes]; + yield [$variance, $benevolent(), new StringType(), $yes, $yes, $yes, $maybe]; + yield [$variance, new MixedType(), new IntegerType(), $yes, $yes, $yes, $maybe]; + } - yield [ - $variance, - new BenevolentUnionType([new IntegerType(), new StringType()]), - new StringType(), - TrinaryLogic::createYes(), - TrinaryLogic::createYes(), - ]; + yield [TemplateTypeVariance::createInvariant(), $benevolent(), new UnionType([new IntegerType(), new StringType()]), $yes, $yes, $yes, $maybe]; + yield [TemplateTypeVariance::createInvariant(), new UnionType([new IntegerType(), new StringType()]), $benevolent(), $yes, $yes, $maybe, $yes]; - yield [ - $variance, - new BenevolentUnionType([new IntegerType(), new StringType()]), - new UnionType([new IntegerType(), new StringType()]), - TrinaryLogic::createYes(), - TrinaryLogic::createYes(), - ]; + yield [TemplateTypeVariance::createCovariant(), $benevolent(), new UnionType([new IntegerType(), new StringType()]), $yes, $yes, $yes, $yes]; + yield [TemplateTypeVariance::createCovariant(), new UnionType([new IntegerType(), new StringType()]), $benevolent(), $yes, $yes, $yes, $yes]; - yield [ - $variance, - new UnionType([new IntegerType(), new StringType()]), - new BenevolentUnionType([new IntegerType(), new StringType()]), - TrinaryLogic::createYes(), - TrinaryLogic::createYes(), - ]; - } + yield [TemplateTypeVariance::createContravariant(), new MixedType(), new IntegerType(), $yes, $yes, $maybe, $yes]; + yield [TemplateTypeVariance::createContravariant(), new IntegerType(), $benevolent(), $yes, $yes, $yes, $maybe]; } #[DataProvider('dataIsValidVariance')] @@ -84,6 +49,8 @@ public function testIsValidVariance( Type $b, TrinaryLogic $expected, TrinaryLogic $expectedInversed, + TrinaryLogic $expectedStrict, + TrinaryLogic $expectedStrictInversed, ): void { $templateType = TemplateTypeFactory::create(TemplateTypeScope::createWithFunction('foo'), 'T', null, $variance); @@ -97,6 +64,16 @@ public function testIsValidVariance( $variance->isValidVariance($templateType, $b, $a)->result->describe(), sprintf('%s->isValidVariance(%s, %s)', $variance->describe(), $b->describe(VerbosityLevel::precise()), $a->describe(VerbosityLevel::precise())), ); + $this->assertSame( + $expectedStrict->describe(), + $variance->isValidVariance($templateType, $a, $b, true)->result->describe(), + sprintf('%s->isValidVariance(%s, %s, true)', $variance->describe(), $a->describe(VerbosityLevel::precise()), $b->describe(VerbosityLevel::precise())), + ); + $this->assertSame( + $expectedStrictInversed->describe(), + $variance->isValidVariance($templateType, $b, $a, true)->result->describe(), + sprintf('%s->isValidVariance(%s, %s, true)', $variance->describe(), $b->describe(VerbosityLevel::precise()), $a->describe(VerbosityLevel::precise())), + ); } } From c6be672ad44966daa6e8147d7eed9cabc1833a4f Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 8 Sep 2026 14:37:26 +0000 Subject: [PATCH 2/2] Cover `mixed` type arguments in both directions with regression tests Pins the gradual-typing leniency the strict variance mode has to keep: `accepts()` stays symmetric for a `mixed` type argument, while `isSuperTypeOf()` only answers `yes` when the `mixed` is on the supertype side. See https://github.com/phpstan/phpstan/issues/11935 Co-Authored-By: Claude Opus 5 --- .../Rules/Functions/CallCallablesRuleTest.php | 24 ++++++++++ .../Rules/Functions/data/bug-11935.php | 44 +++++++++++++++++++ .../Type/Generic/GenericObjectTypeTest.php | 25 +++++++++++ 3 files changed, 93 insertions(+) create mode 100644 tests/PHPStan/Rules/Functions/data/bug-11935.php diff --git a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php index 03448315326..d2a48d3ae62 100644 --- a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php @@ -435,4 +435,28 @@ public function testBug13810(): void $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-13810.php'], []); } + #[RequiresPhp('>= 8.0.0')] + public function testBug11935(): void + { + // Below level 9 mixed is compatible with every type argument in both + // directions - that is how gradual typing works. + $this->analyse([__DIR__ . '/data/bug-11935.php'], []); + } + + #[RequiresPhp('>= 8.0.0')] + public function testBug11935WithCheckExplicitMixed(): void + { + $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-11935.php'], [ + [ + 'Parameter #1 of callable callable(Bug11935\Inv): Bug11935\Inv expects Bug11935\Inv, Bug11935\Inv given.', + 34, + ], + [ + 'Parameter #1 of callable callable(Bug11935\Inv): void expects Bug11935\Inv, Bug11935\Inv given.', + 43, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/bug-11935.php b/tests/PHPStan/Rules/Functions/data/bug-11935.php new file mode 100644 index 00000000000..99a0cfca6b5 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-11935.php @@ -0,0 +1,44 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug11935; + +/** + * @template A + * + * @param mixed $a Intentionally mixed + * @param callable(A): A $aa + * @return A + */ +function identity(mixed $a, callable $aa): mixed +{ + return $aa($a); +} + +/** @template T */ +class Inv +{ + +} + +/** + * @template A + * + * @param Inv $a Intentionally Inv + * @param callable(Inv): Inv $aa + * @return Inv + */ +function identityInv(Inv $a, callable $aa): Inv +{ + return $aa($a); +} + +/** + * @param Inv $a + * @param callable(Inv): void $aa + */ +function passMixedTypeArgument(Inv $a, callable $aa): void +{ + $aa($a); +} diff --git a/tests/PHPStan/Type/Generic/GenericObjectTypeTest.php b/tests/PHPStan/Type/Generic/GenericObjectTypeTest.php index 7564cbe9f33..faca30db63a 100644 --- a/tests/PHPStan/Type/Generic/GenericObjectTypeTest.php +++ b/tests/PHPStan/Type/Generic/GenericObjectTypeTest.php @@ -60,6 +60,20 @@ public static function dataIsSuperTypeOf(): array new GenericObjectType(A\A::class, [new ObjectType('DateTime')]), TrinaryLogic::createNo(), ], + // https://github.com/phpstan/phpstan/issues/11935 - `mixed` as a type + // argument stays compatible with any other one in the accepts context, + // but isSuperTypeOf() has to be a one-way relation, otherwise + // TypeCombinator would discard one of the two based on their order. + 'same class, mixed type arg on the super side' => [ + new GenericObjectType(A\A::class, [new MixedType(true)]), + new GenericObjectType(A\A::class, [new ObjectType('DateTime')]), + TrinaryLogic::createYes(), + ], + 'same class, mixed type arg on the sub side' => [ + new GenericObjectType(A\A::class, [new ObjectType('DateTime')]), + new GenericObjectType(A\A::class, [new MixedType(true)]), + TrinaryLogic::createMaybe(), + ], 'same class, one naked' => [ new GenericObjectType(A\A::class, [new ObjectType('DateTimeInterface')]), new ObjectType(A\A::class), @@ -302,6 +316,17 @@ public static function dataAccepts(): array new GenericObjectType(A\A::class, [new ObjectType('DateTime')]), TrinaryLogic::createNo(), ], + // https://github.com/phpstan/phpstan/issues/11935 + 'same class, mixed type arg on the accepting side' => [ + new GenericObjectType(A\A::class, [new MixedType(true)]), + new GenericObjectType(A\A::class, [new ObjectType('DateTime')]), + TrinaryLogic::createYes(), + ], + 'same class, mixed type arg on the accepted side' => [ + new GenericObjectType(A\A::class, [new ObjectType('DateTime')]), + new GenericObjectType(A\A::class, [new MixedType(true)]), + TrinaryLogic::createYes(), + ], 'same class, one naked' => [ new GenericObjectType(A\A::class, [new ObjectType('DateTimeInterface')]), new ObjectType(A\A::class),