Last active
March 3, 2026 22:41
-
-
Save genesiscz/84e132e2fa6cf8fcc00a952da9de22ab to your computer and use it in GitHub Desktop.
PHPStan bug: @phpstan-assert narrowing broken by method chaining (sub-expression) — see #13902, #5473
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| declare(strict_types=1); | |
| use function PHPStan\dumpType; | |
| /** | |
| * Minimal reproduction: @phpstan-assert narrowing broken by method chaining | |
| * | |
| * @see https://github.com/phpstan/phpstan/issues/13902 | |
| * @see https://github.com/phpstan/phpstan/issues/5473 | |
| */ | |
| class AssertResult | |
| { | |
| private function __construct(public readonly bool $ok) {} | |
| public static function pass(): self { return new self(ok: true); } | |
| public static function fail(): self { return new self(ok: false); } | |
| /** | |
| * Throw the given exception if the assertion failed. | |
| * | |
| * @throws \Throwable | |
| */ | |
| public function throws(\Throwable $exception): void | |
| { | |
| if (! $this->ok) { | |
| throw $exception; | |
| } | |
| } | |
| } | |
| final class Assert | |
| { | |
| /** | |
| * @phpstan-assert !null $value | |
| */ | |
| public static function notNull(mixed $value): AssertResult | |
| { | |
| if ($value !== null) { | |
| return AssertResult::pass(); | |
| } | |
| return AssertResult::fail(); | |
| } | |
| /** | |
| * @phpstan-assert !null $value | |
| * @return void | |
| */ | |
| public static function notNullVoid(mixed $value): void | |
| { | |
| if ($value === null) { | |
| throw new \RuntimeException('Value is null'); | |
| } | |
| } | |
| } | |
| // ─── Case 1: Standalone statement (void return) ─────────────────── | |
| // ✅ WORKS — PHPStan narrows $value to string | |
| function testStandaloneVoid(?string $value): void | |
| { | |
| Assert::notNullVoid($value); | |
| dumpType($value); // string | |
| } | |
| // ─── Case 2: Standalone statement (non-void return) ─────────────── | |
| // ✅ WORKS — PHPStan still narrows $value to string | |
| function testStandaloneNonVoid(?string $value): void | |
| { | |
| Assert::notNull($value); | |
| dumpType($value); // string | |
| } | |
| // ─── Case 3: Chained method call ────────────────────────────────── | |
| // ❌ BROKEN — PHPStan does NOT narrow $value | |
| function testChained(?string $value): void | |
| { | |
| Assert::notNull($value)->throws(new \RuntimeException('Value is null')); | |
| dumpType($value); // string|null (should be string) | |
| } | |
| // ─── Case 4: Assigned to variable ───────────────────────────────── | |
| // ✅ WORKS — PHPStan narrows $value via assignment RHS processing | |
| function testAssigned(?string $value): void | |
| { | |
| $_ = Assert::notNull($value); | |
| dumpType($value); // string | |
| } | |
| // ─── Case 5: Property access on nullable after chained assert ───── | |
| // ❌ BROKEN — same root cause, property access on still-nullable | |
| function testPropertyAccess(?object $cart): void | |
| { | |
| Assert::notNull($cart)->throws(new \RuntimeException('Cart not found')); | |
| dumpType($cart); // object|null (should be object) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment