From 11b329605eee3246b58ef54a1a6fca2f14c14114 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Fri, 31 Jul 2026 23:43:33 +0900 Subject: [PATCH 1/3] Check the subtracted type when a subtracted mixed accepts a value --- src/Rules/RuleLevelHelper.php | 2 +- src/Type/Generic/TemplateStrictMixedType.php | 2 + src/Type/MixedType.php | 14 ++++ src/Type/StrictMixedType.php | 44 ++++++++++- src/Type/VerbosityLevel.php | 24 ++++++ ...ibleInArrayHaystackFiniteTypesRuleTest.php | 2 +- .../CallToFunctionParametersRuleTest.php | 50 ++++++++++++ .../Rules/Functions/ReturnTypeRuleTest.php | 31 ++++++++ .../data/non-empty-mixed-parameter.php | 45 +++++++++++ .../Functions/data/non-empty-mixed-return.php | 45 +++++++++++ tests/PHPStan/Type/MixedTypeTest.php | 78 +++++++++++++++++++ 11 files changed, 332 insertions(+), 5 deletions(-) create mode 100644 tests/PHPStan/Rules/Functions/data/non-empty-mixed-parameter.php create mode 100644 tests/PHPStan/Rules/Functions/data/non-empty-mixed-return.php diff --git a/src/Rules/RuleLevelHelper.php b/src/Rules/RuleLevelHelper.php index 1c1bd926f90..d164a43fb8f 100644 --- a/src/Rules/RuleLevelHelper.php +++ b/src/Rules/RuleLevelHelper.php @@ -74,7 +74,7 @@ private function transformCommonType(Type $type): Type || (!$type->isExplicitMixed() && $this->checkImplicitMixed) ) ) { - return new StrictMixedType(); + return new StrictMixedType($type->getSubtractedType()); } return $traverse($type); diff --git a/src/Type/Generic/TemplateStrictMixedType.php b/src/Type/Generic/TemplateStrictMixedType.php index 6feefd86966..fb935f00329 100644 --- a/src/Type/Generic/TemplateStrictMixedType.php +++ b/src/Type/Generic/TemplateStrictMixedType.php @@ -27,6 +27,8 @@ public function __construct( ?Type $default, ) { + parent::__construct(); + $this->scope = $scope; $this->strategy = $templateTypeStrategy; $this->variance = $templateTypeVariance; diff --git a/src/Type/MixedType.php b/src/Type/MixedType.php index bd91b5286fb..6b73080b95c 100644 --- a/src/Type/MixedType.php +++ b/src/Type/MixedType.php @@ -100,6 +100,20 @@ public function getConstantStrings(): array public function accepts(Type $type, bool $strictTypes): AcceptsResult { + if ( + $this->subtractedType !== null + && !$type instanceof NeverType + && $this->subtractedType->isSuperTypeOf($type)->yes() + ) { + return AcceptsResult::createNo([ + sprintf( + 'Type %s has already been eliminated from %s.', + $this->subtractedType->describe(VerbosityLevel::precise()), + $this->describe(VerbosityLevel::typeOnly()), + ), + ]); + } + return AcceptsResult::createYes(); } diff --git a/src/Type/StrictMixedType.php b/src/Type/StrictMixedType.php index 7477144c93d..063dac76707 100644 --- a/src/Type/StrictMixedType.php +++ b/src/Type/StrictMixedType.php @@ -23,7 +23,9 @@ use PHPStan\Type\Traits\NonGeneralizableTypeTrait; use PHPStan\Type\Traits\NonIterableTypeTrait; use PHPStan\Type\Traits\NonRemoveableTypeTrait; +use PHPStan\Type\Traits\SubstractableTypeTrait; use PHPStan\Type\Traits\UndecidedComparisonCompoundTypeTrait; +use function sprintf; class StrictMixedType implements CompoundType { @@ -33,6 +35,16 @@ class StrictMixedType implements CompoundType use NonIterableTypeTrait; use NonRemoveableTypeTrait; use NonGeneralizableTypeTrait; + use SubstractableTypeTrait; + + public function __construct(private ?Type $subtractedType = null) + { + } + + public function getSubtractedType(): ?Type + { + return $this->subtractedType; + } public function getReferencedClasses(): array { @@ -56,6 +68,20 @@ public function getConstantStrings(): array public function accepts(Type $type, bool $strictTypes): AcceptsResult { + if ( + $this->subtractedType !== null + && !$type instanceof NeverType + && $this->subtractedType->isSuperTypeOf($type)->yes() + ) { + return AcceptsResult::createNo([ + sprintf( + 'Type %s has already been eliminated from %s.', + $this->subtractedType->describe(VerbosityLevel::precise()), + $this->describe(VerbosityLevel::typeOnly()), + ), + ]); + } + return AcceptsResult::createYes(); } @@ -90,7 +116,19 @@ public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult public function equals(Type $type): bool { - return $type instanceof self; + if (!$type instanceof self) { + return false; + } + + if ($this->subtractedType === null) { + return $type->subtractedType === null; + } + + if ($type->subtractedType === null) { + return false; + } + + return $this->subtractedType->equals($type->subtractedType); } public function describe(VerbosityLevel $level): string @@ -98,8 +136,8 @@ public function describe(VerbosityLevel $level): string return $level->handle( static fn () => 'mixed', static fn () => 'mixed', - static fn () => 'mixed', - static fn () => 'strict-mixed', + fn () => 'mixed' . $this->describeSubtractedType($this->subtractedType, $level), + fn () => 'strict-mixed' . $this->describeSubtractedType($this->subtractedType, $level), ); } diff --git a/src/Type/VerbosityLevel.php b/src/Type/VerbosityLevel.php index 5783f649a43..4869b5511c4 100644 --- a/src/Type/VerbosityLevel.php +++ b/src/Type/VerbosityLevel.php @@ -128,6 +128,30 @@ public function isCache(): bool */ public static function getRecommendedLevelByType(Type $acceptingType, ?Type $acceptedType = null): self { + // A subtracted mixed only makes sense in an error message when the subtraction + // is spelled out. Template bounds are skipped - the subtraction there belongs + // to the bound, not to the type being described. + $hasSubtractedMixed = false; + TypeTraverser::map($acceptingType, static function (Type $type, callable $traverse) use (&$hasSubtractedMixed): Type { + if ($hasSubtractedMixed || $type instanceof TemplateType) { + return $type; + } + + if ( + ($type instanceof MixedType || $type instanceof StrictMixedType) + && $type->getSubtractedType() !== null + ) { + $hasSubtractedMixed = true; + return $type; + } + + return $traverse($type); + }); + + if ($hasSubtractedMixed) { + return self::precise(); + } + $moreVerbose = false; $veryVerbose = false; $moreVerboseCallback = static function (Type $type, callable $traverse) use (&$moreVerbose, &$veryVerbose): Type { diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleInArrayHaystackFiniteTypesRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleInArrayHaystackFiniteTypesRuleTest.php index cb135396815..ab8efd4b0f6 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleInArrayHaystackFiniteTypesRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleInArrayHaystackFiniteTypesRuleTest.php @@ -42,7 +42,7 @@ public function testRule(): void 38, ], [ - 'Value \'installed\' in the haystack passed to in_array() can never be identical to the needle type mixed.', + 'Value \'installed\' in the haystack passed to in_array() can never be identical to the needle type mixed~\'installed\'.', 99, 'Type \'installed\' has already been eliminated from mixed.', ], diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index df3def0c39d..c6d8f49cfce 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -3018,6 +3018,56 @@ public function testBug11894(): void $this->analyse([__DIR__ . '/data/bug-11894.php'], []); } + public static function dataNonEmptyMixedParameter(): iterable + { + yield [false]; + yield [true]; + } + + #[DataProvider('dataNonEmptyMixedParameter')] + public function testNonEmptyMixedParameter(bool $checkExplicitMixed): void + { + $this->checkExplicitMixed = $checkExplicitMixed; + $this->checkImplicitMixed = $checkExplicitMixed; + $this->analyse([__DIR__ . '/data/non-empty-mixed-parameter.php'], [ + [ + 'Parameter #1 $value of function NonEmptyMixedParameter\acceptsNonEmptyMixed expects mixed~(0|0.0|\'\'|\'0\'|array{}|false|null), \'\' given.', + 17, + 'Type 0|0.0|\'\'|\'0\'|array{}|false|null has already been eliminated from mixed.', + ], + [ + 'Parameter #1 $value of function NonEmptyMixedParameter\acceptsNonEmptyMixed expects mixed~(0|0.0|\'\'|\'0\'|array{}|false|null), \'0\' given.', + 18, + 'Type 0|0.0|\'\'|\'0\'|array{}|false|null has already been eliminated from mixed.', + ], + [ + 'Parameter #1 $value of function NonEmptyMixedParameter\acceptsNonEmptyMixed expects mixed~(0|0.0|\'\'|\'0\'|array{}|false|null), 0 given.', + 19, + 'Type 0|0.0|\'\'|\'0\'|array{}|false|null has already been eliminated from mixed.', + ], + [ + 'Parameter #1 $value of function NonEmptyMixedParameter\acceptsNonEmptyMixed expects mixed~(0|0.0|\'\'|\'0\'|array{}|false|null), 0.0 given.', + 20, + 'Type 0|0.0|\'\'|\'0\'|array{}|false|null has already been eliminated from mixed.', + ], + [ + 'Parameter #1 $value of function NonEmptyMixedParameter\acceptsNonEmptyMixed expects mixed~(0|0.0|\'\'|\'0\'|array{}|false|null), array{} given.', + 21, + 'Type 0|0.0|\'\'|\'0\'|array{}|false|null has already been eliminated from mixed.', + ], + [ + 'Parameter #1 $value of function NonEmptyMixedParameter\acceptsNonEmptyMixed expects mixed~(0|0.0|\'\'|\'0\'|array{}|false|null), false given.', + 22, + 'Type 0|0.0|\'\'|\'0\'|array{}|false|null has already been eliminated from mixed.', + ], + [ + 'Parameter #1 $value of function NonEmptyMixedParameter\acceptsNonEmptyMixed expects mixed~(0|0.0|\'\'|\'0\'|array{}|false|null), null given.', + 23, + 'Type 0|0.0|\'\'|\'0\'|array{}|false|null has already been eliminated from mixed.', + ], + ]); + } + public function testBug11494(): void { $this->analyse([__DIR__ . '/data/bug-11494.php'], [ diff --git a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php index 88a4b5562cf..3bc7c388c78 100644 --- a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php @@ -6,6 +6,7 @@ use PHPStan\Rules\Rule; use PHPStan\Rules\RuleLevelHelper; use PHPStan\Testing\RuleTestCase; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhp; /** @@ -438,6 +439,36 @@ public function testBug14428(): void $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14428.php'], []); } + public static function dataNonEmptyMixedReturn(): iterable + { + yield [false]; + yield [true]; + } + + #[DataProvider('dataNonEmptyMixedReturn')] + public function testNonEmptyMixedReturn(bool $checkExplicitMixed): void + { + $this->checkNullables = true; + $this->checkExplicitMixed = $checkExplicitMixed; + $this->analyse([__DIR__ . '/data/non-empty-mixed-return.php'], [ + [ + 'Function NonEmptyMixedReturn\returnsEmptyString() should return mixed~(0|0.0|\'\'|\'0\'|array{}|false|null) but returns \'\'.', + 8, + 'Type 0|0.0|\'\'|\'0\'|array{}|false|null has already been eliminated from mixed.', + ], + [ + 'Function NonEmptyMixedReturn\returnsNull() should return mixed~(0|0.0|\'\'|\'0\'|array{}|false|null) but returns null.', + 14, + 'Type 0|0.0|\'\'|\'0\'|array{}|false|null has already been eliminated from mixed.', + ], + [ + 'Function NonEmptyMixedReturn\returnsEmptyArray() should return mixed~(0|0.0|\'\'|\'0\'|array{}|false|null) but returns array{}.', + 20, + 'Type 0|0.0|\'\'|\'0\'|array{}|false|null has already been eliminated from mixed.', + ], + ]); + } + public function testBug13565(): void { $this->checkNullables = true; diff --git a/tests/PHPStan/Rules/Functions/data/non-empty-mixed-parameter.php b/tests/PHPStan/Rules/Functions/data/non-empty-mixed-parameter.php new file mode 100644 index 00000000000..65f01824a69 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/non-empty-mixed-parameter.php @@ -0,0 +1,45 @@ +accepts($otherType, true)->result; + $this->assertSame( + $expectedResult->describe(), + $actualResult->describe(), + sprintf('%s -> accepts(%s)', $type->describe(VerbosityLevel::precise()), $otherType->describe(VerbosityLevel::precise())), + ); + } + public static function dataSubstractedIsArray(): array { return [ From f5a63a1b41fab434e9455b38cbb1b0253e621e62 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Mon, 3 Aug 2026 22:55:18 +0900 Subject: [PATCH 2/3] Carry the subtracted type into the remaining StrictMixedType conversions --- src/Rules/RuleLevelHelper.php | 2 +- src/Type/Generic/TemplateMixedType.php | 2 +- .../Rules/Classes/ClassConstantRuleTest.php | 8 ++++++ .../dynamic-constant-stringable-access.php | 27 +++++++++++++++++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/Rules/RuleLevelHelper.php b/src/Rules/RuleLevelHelper.php index d164a43fb8f..6bb358d781b 100644 --- a/src/Rules/RuleLevelHelper.php +++ b/src/Rules/RuleLevelHelper.php @@ -208,7 +208,7 @@ private function findTypeToCheckImplementation( return new FoundTypeResult( $type instanceof TemplateMixedType ? $type->toStrictMixedType() - : new StrictMixedType(), + : new StrictMixedType($type->getSubtractedType()), [], [], null, diff --git a/src/Type/Generic/TemplateMixedType.php b/src/Type/Generic/TemplateMixedType.php index d7729cc3530..b62059ab342 100644 --- a/src/Type/Generic/TemplateMixedType.php +++ b/src/Type/Generic/TemplateMixedType.php @@ -58,7 +58,7 @@ public function toStrictMixedType(): TemplateStrictMixedType $this->strategy, $this->variance, $this->name, - new StrictMixedType(), + new StrictMixedType($this->getSubtractedType()), $this->default, ); } diff --git a/tests/PHPStan/Rules/Classes/ClassConstantRuleTest.php b/tests/PHPStan/Rules/Classes/ClassConstantRuleTest.php index a6109480c32..27d7e8ca326 100644 --- a/tests/PHPStan/Rules/Classes/ClassConstantRuleTest.php +++ b/tests/PHPStan/Rules/Classes/ClassConstantRuleTest.php @@ -566,6 +566,14 @@ public function testStringableDynamicAccess(): void 'Class constant name for object must be a string, but mixed was given.', 39, ], + [ + 'Class constant name for ClassConstantDynamicStringableAccess\Baz must be a string, but mixed~null was given.', + 53, + ], + [ + 'Class constant name for ClassConstantDynamicStringableAccess\Baz must be a string, but T of mixed~null (method ClassConstantDynamicStringableAccess\Baz::testSubtractedTemplate(), argument) was given.', + 66, + ], ]); } diff --git a/tests/PHPStan/Rules/Classes/data/dynamic-constant-stringable-access.php b/tests/PHPStan/Rules/Classes/data/dynamic-constant-stringable-access.php index e944e19947c..fabacd9de3a 100644 --- a/tests/PHPStan/Rules/Classes/data/dynamic-constant-stringable-access.php +++ b/tests/PHPStan/Rules/Classes/data/dynamic-constant-stringable-access.php @@ -40,3 +40,30 @@ public function testClassDynamic(DateTime|DateTimeImmutable $datetime, object $o } } + +final class Baz +{ + + public function testSubtractedMixed(mixed $mixed): void + { + if ($mixed === null) { + return; + } + + echo self::{$mixed}; + } + + /** + * @template T + * @param T $name + */ + public function testSubtractedTemplate($name): void + { + if ($name === null) { + return; + } + + echo self::{$name}; + } + +} From 14d40d36aadf075e69630aa12291bc5f5243547e Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 5 Aug 2026 16:18:14 +0900 Subject: [PATCH 3/3] Detect subtracted mixed within the single verbosity traversal instead of a pre-pass --- src/Type/VerbosityLevel.php | 49 ++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/src/Type/VerbosityLevel.php b/src/Type/VerbosityLevel.php index 4869b5511c4..8da43690493 100644 --- a/src/Type/VerbosityLevel.php +++ b/src/Type/VerbosityLevel.php @@ -128,33 +128,9 @@ public function isCache(): bool */ public static function getRecommendedLevelByType(Type $acceptingType, ?Type $acceptedType = null): self { - // A subtracted mixed only makes sense in an error message when the subtraction - // is spelled out. Template bounds are skipped - the subtraction there belongs - // to the bound, not to the type being described. - $hasSubtractedMixed = false; - TypeTraverser::map($acceptingType, static function (Type $type, callable $traverse) use (&$hasSubtractedMixed): Type { - if ($hasSubtractedMixed || $type instanceof TemplateType) { - return $type; - } - - if ( - ($type instanceof MixedType || $type instanceof StrictMixedType) - && $type->getSubtractedType() !== null - ) { - $hasSubtractedMixed = true; - return $type; - } - - return $traverse($type); - }); - - if ($hasSubtractedMixed) { - return self::precise(); - } - $moreVerbose = false; $veryVerbose = false; - $moreVerboseCallback = static function (Type $type, callable $traverse) use (&$moreVerbose, &$veryVerbose): Type { + $flagsCallback = static function (Type $type, callable $traverse) use (&$moreVerbose, &$veryVerbose): Type { // stop deep traversal to not waste resources. if ($veryVerbose) { return $type; @@ -212,6 +188,29 @@ public static function getRecommendedLevelByType(Type $acceptingType, ?Type $acc } return $traverse($type); }; + $moreVerboseCallback = static function (Type $type, callable $traverse) use (&$veryVerbose, $flagsCallback): Type { + // stop deep traversal to not waste resources. + if ($veryVerbose) { + return $type; + } + + // A subtracted mixed only makes sense in an error message when the subtraction + // is spelled out. Template subtrees switch to the plain flags callback - + // the subtraction there belongs to the bound, not to the type being described. + if ($type instanceof TemplateType) { + TypeTraverser::map($type, $flagsCallback); + return $type; + } + if ( + ($type instanceof MixedType || $type instanceof StrictMixedType) + && $type->getSubtractedType() !== null + ) { + $veryVerbose = true; + return $type; + } + + return $flagsCallback($type, $traverse); + }; TypeTraverser::map($acceptingType, $moreVerboseCallback);