From 8918e7290f52558d6e09bca16cf2986221a0754a Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 17 Feb 2026 16:44:40 +0100 Subject: [PATCH] Fix TypeError dead catch when assigning mixed to int in property --- src/Analyser/NodeScopeResolver.php | 17 +++- .../CatchWithUnthrownExceptionRuleTest.php | 28 ++++++ .../Exceptions/data/bug-9146-non-strict.php | 97 +++++++++++++++++++ .../Rules/Exceptions/data/bug-9146.php | 97 +++++++++++++++++++ 4 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Rules/Exceptions/data/bug-9146-non-strict.php create mode 100644 tests/PHPStan/Rules/Exceptions/data/bug-9146.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 8a807ea745..ee1297d929 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -6366,8 +6366,23 @@ private function processAssignVar( $declaringClass = $propertyReflection->getDeclaringClass(); if ($declaringClass->hasNativeProperty($propertyName)) { $nativeProperty = $declaringClass->getNativeProperty($propertyName); + $propertyNativeType = $nativeProperty->getNativeType(); + + $assignedTypeIsCompatible = $propertyNativeType->isSuperTypeOf($assignedExprType)->yes(); + if (!$assignedTypeIsCompatible && !$assignedExprType instanceof MixedType) { + foreach (TypeUtils::flattenTypes($assignedExprType->toCoercedArgumentType(true)) as $type) { + $accepts = $propertyNativeType->accepts($type, true); + if ($accepts->yes()) { + $assignedTypeIsCompatible = true; + continue; + } + $assignedTypeIsCompatible = false; + break; + } + } + if ( - !$nativeProperty->getNativeType()->accepts($assignedExprType, true)->yes() + !$assignedTypeIsCompatible ) { $throwPoints[] = InternalThrowPoint::createExplicit($scope, new ObjectType(TypeError::class), $assignedExpr, false); } diff --git a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php index 272c010d97..fd6fc7d88a 100644 --- a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php @@ -643,4 +643,32 @@ public function testPropertyHooks(): void ]); } + public function testBug9146(): void + { + $this->analyse([__DIR__ . '/data/bug-9146.php'], [ + [ + 'Dead catch - TypeError is never thrown in the try block.', + 52, + ], + [ + 'Dead catch - TypeError is never thrown in the try block.', + 80, + ], + ]); + } + + public function testBug9146NonStrict(): void + { + $this->analyse([__DIR__ . '/data/bug-9146-non-strict.php'], [ + [ + 'Dead catch - TypeError is never thrown in the try block.', + 52, + ], + [ + 'Dead catch - TypeError is never thrown in the try block.', + 80, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Exceptions/data/bug-9146-non-strict.php b/tests/PHPStan/Rules/Exceptions/data/bug-9146-non-strict.php new file mode 100644 index 0000000000..fd9c9a85ab --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/bug-9146-non-strict.php @@ -0,0 +1,97 @@ +number = $number; + } catch (\TypeError $e) { + throw new \UnexpectedValueException(); + } + } +} + +final class HelloWorld2 +{ + public string $name; + public function setName(mixed $value): void + { + try { + $this->name = $value; + } catch (\TypeError $e) { + throw new \InvalidArgumentException('Expected string'); + } + } +} + +final class HelloWorld3 +{ + public float $amount; + public function setAmount(mixed $value): void + { + try { + $this->amount = $value; + } catch (\TypeError $e) { + echo "caught"; + } + } +} + +// Dead catch: int assigned to ?float, PHP coerces int to float without TypeError +final class FloatNullCoercion +{ + public ?float $amount; + public function setAmount(int $value): void + { + try { + $this->amount = $value; + } catch (\TypeError $e) { // error: Dead catch - TypeError is never thrown in the try block. + echo "caught"; + } + } +} + +// Not dead: int|string assigned to int, string part could throw TypeError +final class PartialTypeMatch +{ + public int $number; + /** @param int|string $value */ + public function setNumber($value): void + { + try { + $this->number = $value; + } catch (\TypeError $e) { + echo "caught"; + } + } +} + +final class MixedWillNotThrow +{ + public mixed $name; + public function setName(mixed $value): void + { + try { + $this->name = $value; + } catch (\TypeError $e) { + throw new \InvalidArgumentException('Expected string'); + } + } +} + +final class IntJustWarnsOnFloat +{ + public int $amount; + public function setAmount(float $value): void + { + try { + $this->amount = $value; + } catch (\TypeError $e) { + echo "caught"; + } + } +} diff --git a/tests/PHPStan/Rules/Exceptions/data/bug-9146.php b/tests/PHPStan/Rules/Exceptions/data/bug-9146.php new file mode 100644 index 0000000000..2572869e08 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/bug-9146.php @@ -0,0 +1,97 @@ +number = $number; + } catch (\TypeError $e) { + throw new \UnexpectedValueException(); + } + } +} + +final class HelloWorld2 +{ + public string $name; + public function setName(mixed $value): void + { + try { + $this->name = $value; + } catch (\TypeError $e) { + throw new \InvalidArgumentException('Expected string'); + } + } +} + +final class HelloWorld3 +{ + public float $amount; + public function setAmount(mixed $value): void + { + try { + $this->amount = $value; + } catch (\TypeError $e) { + echo "caught"; + } + } +} + +// Dead catch: int assigned to ?float, PHP coerces int to float without TypeError +final class FloatNullCoercion +{ + public ?float $amount; + public function setAmount(int $value): void + { + try { + $this->amount = $value; + } catch (\TypeError $e) { // error: Dead catch - TypeError is never thrown in the try block. + echo "caught"; + } + } +} + +// Not dead: int|string assigned to int, string part could throw TypeError +final class PartialTypeMatch +{ + public int $number; + /** @param int|string $value */ + public function setNumber($value): void + { + try { + $this->number = $value; + } catch (\TypeError $e) { + echo "caught"; + } + } +} + +final class MixedWillNotThrow +{ + public mixed $name; + public function setName(mixed $value): void + { + try { + $this->name = $value; + } catch (\TypeError $e) { + throw new \InvalidArgumentException('Expected string'); + } + } +} + +final class IntDoesNotAcceptFloat +{ + public int $amount; + public function setAmount(float $value): void + { + try { + $this->amount = $value; + } catch (\TypeError $e) { + echo "caught"; + } + } +}