From 64fbc7b4350ed3be3be0e7c7309d162876aa6690 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:21:23 +0000 Subject: [PATCH 1/5] Narrow array&callable intersection to list{class-string|object, string} When TypeCombinator::intersect() combines an ArrayType with a CallableType, replace the plain ArrayType with a ConstantArrayType representing array{class-string|object, string}. This reflects the fact that callable arrays in PHP are always two-element lists with a class-string or object at index 0 and a method name string at index 1. This fixes the issue where array&callable was too wide, causing: - Iteration over the intersection to yield mixed keys/values - Parameter type checking to accept array&callable where array is expected Fixes phpstan/phpstan#14549 --- phpstan-baseline.neon | 4 +- src/Type/TypeCombinator.php | 40 ++++++++++++ tests/PHPStan/Analyser/nsrt/bug-12393.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-12393b.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-14549.php | 63 +++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-3842.php | 2 +- .../Rules/Methods/CallMethodsRuleTest.php | 13 ++++ .../PHPStan/Rules/Methods/data/bug-14549.php | 20 ++++++ 8 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14549.php create mode 100644 tests/PHPStan/Rules/Methods/data/bug-14549.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ec7779bfbed..9422e6dc409 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1692,13 +1692,13 @@ parameters: - rawMessage: 'Doing instanceof PHPStan\Type\ArrayType is error-prone and deprecated. Use Type::isArray() or Type::getArrays() instead.' identifier: phpstanApi.instanceofType - count: 5 + count: 7 path: src/Type/TypeCombinator.php - rawMessage: 'Doing instanceof PHPStan\Type\CallableType is error-prone and deprecated. Use Type::isCallable() and Type::getCallableParametersAcceptors() instead.' identifier: phpstanApi.instanceofType - count: 1 + count: 3 path: src/Type/TypeCombinator.php - diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 8181f93fe0d..730f8239cb0 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1683,6 +1683,46 @@ public static function intersect(Type ...$types): Type continue; } + if ( + $types[$i] instanceof ArrayType + && get_class($types[$i]) === ArrayType::class + && $types[$j] instanceof CallableType + ) { + $existingValueType = $types[$i]->getItemType(); + $offset0ValueType = self::intersect($existingValueType, new UnionType([new ClassStringType(), new ObjectWithoutClassType()])); + $offset1ValueType = self::intersect($existingValueType, new StringType()); + if ($offset0ValueType instanceof NeverType || $offset1ValueType instanceof NeverType) { + return new NeverType(); + } + $types[$i] = new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantIntegerType(1)], + [$offset0ValueType, $offset1ValueType], + [2], + isList: TrinaryLogic::createYes(), + ); + continue; + } + + if ( + $types[$j] instanceof ArrayType + && get_class($types[$j]) === ArrayType::class + && $types[$i] instanceof CallableType + ) { + $existingValueType = $types[$j]->getItemType(); + $offset0ValueType = self::intersect($existingValueType, new UnionType([new ClassStringType(), new ObjectWithoutClassType()])); + $offset1ValueType = self::intersect($existingValueType, new StringType()); + if ($offset0ValueType instanceof NeverType || $offset1ValueType instanceof NeverType) { + return new NeverType(); + } + $types[$j] = new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantIntegerType(1)], + [$offset0ValueType, $offset1ValueType], + [2], + isList: TrinaryLogic::createYes(), + ); + continue; + } + continue; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-12393.php b/tests/PHPStan/Analyser/nsrt/bug-12393.php index 4edd2300c13..813dc3b7b46 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-12393.php +++ b/tests/PHPStan/Analyser/nsrt/bug-12393.php @@ -160,7 +160,7 @@ class CallableArray { public function doFoo(callable $foo): void { $this->foo = $foo; - assertType('array', $this->foo); // could be non-empty-array + assertType('array', $this->foo); // could be non-empty-array } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-12393b.php b/tests/PHPStan/Analyser/nsrt/bug-12393b.php index a21dcf561a0..944646d3656 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-12393b.php +++ b/tests/PHPStan/Analyser/nsrt/bug-12393b.php @@ -669,7 +669,7 @@ class CallableArray { public function doFoo(callable $foo): void { $this->foo = $foo; - assertType('array', $this->foo); // could be non-empty-array + assertType('array', $this->foo); // could be non-empty-array } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14549.php b/tests/PHPStan/Analyser/nsrt/bug-14549.php new file mode 100644 index 00000000000..bfa4c8372e0 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14549.php @@ -0,0 +1,63 @@ + $value) { + assertType('object|string', $value); + assertType('0|1', $key); + } + } + } + + public function testCallableArrayIterableTypes(callable $value): void + { + if (is_array($value)) { + assertType('list{class-string|object, string}&callable(): mixed', $value); + + foreach ($value as $key => $val) { + assertType('0|1', $key); + assertType('object|string', $val); + } + } + } + + /** @param array{string, string} $task */ + public function testConstantArrayNarrowing(array $task): void + { + if (\is_callable($task)) { + // ConstantArrayType keeps its shape, callable narrows offset access + assertType('list{string, string}&callable(): mixed', $task); + assertType('class-string', $task[0]); + assertType('string', $task[1]); + } + } + + /** @param array $task */ + public function testTypedArrayNarrowing(array $task): void + { + if (\is_callable($task)) { + // When value type is string, intersect with class-string|object gives class-string + // and intersect with string gives string + assertType('list{class-string, string}&callable(): mixed', $task); + } + } + + /** @param callable-array $value */ + public function testCallableArrayPhpDoc(array $value): void + { + assertType('array&callable(): mixed', $value); + assertType('class-string|object', $value[0]); + assertType('string', $value[1]); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-3842.php b/tests/PHPStan/Analyser/nsrt/bug-3842.php index 51e607ea409..ebcb4328545 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-3842.php +++ b/tests/PHPStan/Analyser/nsrt/bug-3842.php @@ -20,7 +20,7 @@ public function callback(): void function testIsArrayOnCallable(callable $value): void { if (is_array($value)) { - assertType('array&callable(): mixed', $value); + assertType('list{class-string|object, string}&callable(): mixed', $value); assertType('class-string|object', $value[0]); assertType('string', $value[1]); } diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 9a2a8e1a9f9..762adc57f36 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -4017,4 +4017,17 @@ public function testBug13272(): void $this->analyse([__DIR__ . '/data/bug-13272.php'], []); } + public function testBug14549(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-14549.php'], [ + [ + 'Parameter #1 $task of method Bug14549Rule\Foo::call() expects array, callable&list given.', + 10, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-14549.php b/tests/PHPStan/Rules/Methods/data/bug-14549.php new file mode 100644 index 00000000000..51e05189c2f --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-14549.php @@ -0,0 +1,20 @@ +call($task); + } + } + + /** + * @param array $task + */ + public function call(array $task): void + { + } +} From 0f240cc315cdf5693640678cec33d0330fea465a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 28 Apr 2026 22:58:58 +0000 Subject: [PATCH 2/5] Narrow ConstantArrayType with callable and remove IntersectionType special behavior Move callable-array narrowing logic entirely into TypeCombinator::intersect(): - Add ConstantArrayType + CallableType handling to narrow value types at offsets 0 and 1 - Update callable-array PHPDoc resolution to use TypeCombinator::intersect() - Remove redundant hasOffsetValueType/getOffsetValueType callable logic from IntersectionType Co-Authored-By: Claude Opus 4.6 --- phpstan-baseline.neon | 4 +- src/PhpDoc/TypeNodeResolver.php | 2 +- src/Type/IntersectionType.php | 23 ---------- src/Type/TypeCombinator.php | 51 ++++++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-14549.php | 5 +-- tests/PHPStan/Analyser/nsrt/bug-3842.php | 2 +- tests/PHPStan/Analyser/nsrt/more-types.php | 2 +- 7 files changed, 58 insertions(+), 31 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 9422e6dc409..8a28e579a7e 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1698,13 +1698,13 @@ parameters: - rawMessage: 'Doing instanceof PHPStan\Type\CallableType is error-prone and deprecated. Use Type::isCallable() and Type::getCallableParametersAcceptors() instead.' identifier: phpstanApi.instanceofType - count: 3 + count: 5 path: src/Type/TypeCombinator.php - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantArrayType is error-prone and deprecated. Use Type::getConstantArrays() instead.' identifier: phpstanApi.instanceofType - count: 19 + count: 21 path: src/Type/TypeCombinator.php - diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index bd243ef7d26..2bc8da6f970 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -429,7 +429,7 @@ private function resolveIdentifierTypeNode(IdentifierTypeNode $typeNode, NameSco return new IntersectionType([new ObjectWithoutClassType(), new CallableType()]); case 'callable-array': - return new IntersectionType([new ArrayType(new MixedType(), new MixedType()), new CallableType()]); + return TypeCombinator::intersect(new ArrayType(new MixedType(), new MixedType()), new CallableType()); case 'never': case 'noreturn': diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 81e6d3272da..e40b753e04d 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -945,14 +945,6 @@ public function hasOffsetValueType(Type $offsetType): TrinaryLogic $result = $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasOffsetValueType($offsetType)); - if (!$result->yes() && $this->isCallable()->yes() && $this->isArray()->yes()) { - $arrayKeyOffsetType = $offsetType->toArrayKey(); - $callableArrayOffsetType = new UnionType([new ConstantIntegerType(0), new ConstantIntegerType(1)]); - if ($callableArrayOffsetType->isSuperTypeOf($arrayKeyOffsetType)->yes()) { - return TrinaryLogic::createYes(); - } - } - return $result; } @@ -963,21 +955,6 @@ public function getOffsetValueType(Type $offsetType): Type return TypeUtils::toBenevolentUnion($result); } - if ($this->isCallable()->yes() && $this->isArray()->yes()) { - $arrayKeyOffsetType = $offsetType->toArrayKey(); - $callableArrayOffsetType = new UnionType([new ConstantIntegerType(0), new ConstantIntegerType(1)]); - if ($callableArrayOffsetType->isSuperTypeOf($arrayKeyOffsetType)->yes()) { - if ((new ConstantIntegerType(0))->isSuperTypeOf($arrayKeyOffsetType)->yes()) { - $narrowedType = new UnionType([new ClassStringType(), new ObjectWithoutClassType()]); - } elseif ((new ConstantIntegerType(1))->isSuperTypeOf($arrayKeyOffsetType)->yes()) { - $narrowedType = new StringType(); - } else { - $narrowedType = new UnionType([new StringType(), new ObjectWithoutClassType()]); - } - $result = TypeCombinator::intersect($result, $narrowedType); - } - } - return $result; } diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 730f8239cb0..74e4fb71eff 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1723,6 +1723,28 @@ public static function intersect(Type ...$types): Type continue; } + if ( + $types[$i] instanceof ConstantArrayType + && $types[$j] instanceof CallableType + ) { + $types[$i] = self::narrowConstantArrayWithCallable($types[$i]); + if ($types[$i] instanceof NeverType) { + return new NeverType(); + } + continue; + } + + if ( + $types[$j] instanceof ConstantArrayType + && $types[$i] instanceof CallableType + ) { + $types[$j] = self::narrowConstantArrayWithCallable($types[$j]); + if ($types[$j] instanceof NeverType) { + return new NeverType(); + } + continue; + } + continue; } @@ -1819,4 +1841,33 @@ public static function removeTruthy(Type $type): Type return self::remove($type, StaticTypeFactory::truthy()); } + private static function narrowConstantArrayWithCallable(ConstantArrayType $constantArray): Type + { + $keyTypes = $constantArray->getKeyTypes(); + $valueTypes = $constantArray->getValueTypes(); + $newValueTypes = $valueTypes; + + foreach ($keyTypes as $k => $keyType) { + if ((new ConstantIntegerType(0))->isSuperTypeOf($keyType)->yes()) { + $newValueTypes[$k] = self::intersect($valueTypes[$k], new UnionType([new ClassStringType(), new ObjectWithoutClassType()])); + if ($newValueTypes[$k] instanceof NeverType) { + return new NeverType(); + } + } elseif ((new ConstantIntegerType(1))->isSuperTypeOf($keyType)->yes()) { + $newValueTypes[$k] = self::intersect($valueTypes[$k], new StringType()); + if ($newValueTypes[$k] instanceof NeverType) { + return new NeverType(); + } + } + } + + return new ConstantArrayType( + $keyTypes, + $newValueTypes, + $constantArray->getNextAutoIndexes(), + $constantArray->getOptionalKeys(), + $constantArray->isList(), + ); + } + } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14549.php b/tests/PHPStan/Analyser/nsrt/bug-14549.php index bfa4c8372e0..ad95b7ac63d 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14549.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14549.php @@ -36,8 +36,7 @@ public function testCallableArrayIterableTypes(callable $value): void public function testConstantArrayNarrowing(array $task): void { if (\is_callable($task)) { - // ConstantArrayType keeps its shape, callable narrows offset access - assertType('list{string, string}&callable(): mixed', $task); + assertType('list{class-string, string}&callable(): mixed', $task); assertType('class-string', $task[0]); assertType('string', $task[1]); } @@ -56,7 +55,7 @@ public function testTypedArrayNarrowing(array $task): void /** @param callable-array $value */ public function testCallableArrayPhpDoc(array $value): void { - assertType('array&callable(): mixed', $value); + assertType('list{class-string|object, string}&callable(): mixed', $value); assertType('class-string|object', $value[0]); assertType('string', $value[1]); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-3842.php b/tests/PHPStan/Analyser/nsrt/bug-3842.php index ebcb4328545..2f05197da56 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-3842.php +++ b/tests/PHPStan/Analyser/nsrt/bug-3842.php @@ -28,7 +28,7 @@ function testIsArrayOnCallable(callable $value): void { /** @param callable-array $value */ function testCallableArrayPhpDoc(array $value): void { - assertType('array&callable(): mixed', $value); + assertType('list{class-string|object, string}&callable(): mixed', $value); assertType('class-string|object', $value[0]); assertType('string', $value[1]); } diff --git a/tests/PHPStan/Analyser/nsrt/more-types.php b/tests/PHPStan/Analyser/nsrt/more-types.php index c8ae927b2d8..5f51f9e5793 100644 --- a/tests/PHPStan/Analyser/nsrt/more-types.php +++ b/tests/PHPStan/Analyser/nsrt/more-types.php @@ -39,7 +39,7 @@ public function doFoo( ): void { assertType('pure-callable(): mixed', $pureCallable); - assertType('array&callable(): mixed', $callableArray); + assertType('list{class-string|object, string}&callable(): mixed', $callableArray); assertType('resource', $closedResource); assertType('resource', $openResource); assertType('class-string', $enumString); From fa7b3c688434b7b4d966053b9ffefe6bc4313d2c Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 29 Apr 2026 04:56:20 +0000 Subject: [PATCH 3/5] Require both offsets 0 and 1 before narrowing ConstantArrayType with callable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of narrowing keys 0 and 1 independently, first verify both are present — a valid callable array requires both elements. Co-Authored-By: Claude Opus 4.6 --- src/Type/TypeCombinator.php | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 74e4fb71eff..d5799b26032 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1847,17 +1847,25 @@ private static function narrowConstantArrayWithCallable(ConstantArrayType $const $valueTypes = $constantArray->getValueTypes(); $newValueTypes = $valueTypes; + $offset0Index = null; + $offset1Index = null; + foreach ($keyTypes as $k => $keyType) { if ((new ConstantIntegerType(0))->isSuperTypeOf($keyType)->yes()) { - $newValueTypes[$k] = self::intersect($valueTypes[$k], new UnionType([new ClassStringType(), new ObjectWithoutClassType()])); - if ($newValueTypes[$k] instanceof NeverType) { - return new NeverType(); - } + $offset0Index = $k; } elseif ((new ConstantIntegerType(1))->isSuperTypeOf($keyType)->yes()) { - $newValueTypes[$k] = self::intersect($valueTypes[$k], new StringType()); - if ($newValueTypes[$k] instanceof NeverType) { - return new NeverType(); - } + $offset1Index = $k; + } + } + + if ($offset0Index !== null && $offset1Index !== null) { + $newValueTypes[$offset0Index] = self::intersect($valueTypes[$offset0Index], new UnionType([new ClassStringType(), new ObjectWithoutClassType()])); + if ($newValueTypes[$offset0Index] instanceof NeverType) { + return new NeverType(); + } + $newValueTypes[$offset1Index] = self::intersect($valueTypes[$offset1Index], new StringType()); + if ($newValueTypes[$offset1Index] instanceof NeverType) { + return new NeverType(); } } From 65daf7c52ffab92092ffe2541fccac256db37b6a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 29 Apr 2026 05:02:11 +0000 Subject: [PATCH 4/5] Return NeverType early for constant arrays too small to be callable When `narrowConstantArrayWithCallable` receives a ConstantArrayType with fewer than 2 keys, or without both offsets 0 and 1, return NeverType immediately instead of constructing an unchanged ConstantArrayType. Co-Authored-By: Claude Opus 4.6 --- src/Type/TypeCombinator.php | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index d5799b26032..f58bd008cdd 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1845,7 +1845,10 @@ private static function narrowConstantArrayWithCallable(ConstantArrayType $const { $keyTypes = $constantArray->getKeyTypes(); $valueTypes = $constantArray->getValueTypes(); - $newValueTypes = $valueTypes; + + if (count($keyTypes) < 2) { + return new NeverType(); + } $offset0Index = null; $offset1Index = null; @@ -1858,15 +1861,18 @@ private static function narrowConstantArrayWithCallable(ConstantArrayType $const } } - if ($offset0Index !== null && $offset1Index !== null) { - $newValueTypes[$offset0Index] = self::intersect($valueTypes[$offset0Index], new UnionType([new ClassStringType(), new ObjectWithoutClassType()])); - if ($newValueTypes[$offset0Index] instanceof NeverType) { - return new NeverType(); - } - $newValueTypes[$offset1Index] = self::intersect($valueTypes[$offset1Index], new StringType()); - if ($newValueTypes[$offset1Index] instanceof NeverType) { - return new NeverType(); - } + if ($offset0Index === null || $offset1Index === null) { + return new NeverType(); + } + + $newValueTypes = $valueTypes; + $newValueTypes[$offset0Index] = self::intersect($valueTypes[$offset0Index], new UnionType([new ClassStringType(), new ObjectWithoutClassType()])); + if ($newValueTypes[$offset0Index] instanceof NeverType) { + return new NeverType(); + } + $newValueTypes[$offset1Index] = self::intersect($valueTypes[$offset1Index], new StringType()); + if ($newValueTypes[$offset1Index] instanceof NeverType) { + return new NeverType(); } return new ConstantArrayType( From fae693fa46e8507d46389251f443f0c6ab18031a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 29 Apr 2026 05:26:01 +0000 Subject: [PATCH 5/5] Check key type compatibility when narrowing ArrayType with callable A plain ArrayType carries a key type (e.g. array has StringType keys). Since callable arrays always require integer keys 0 and 1, verify the key type is compatible with integers before narrowing. Extract the inline logic into narrowArrayTypeWithCallable for clarity. Co-Authored-By: Claude Opus 4.6 --- src/Type/TypeCombinator.php | 47 +++++++++++++---------- tests/PHPStan/Analyser/nsrt/bug-14549.php | 8 ++++ 2 files changed, 35 insertions(+), 20 deletions(-) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index f58bd008cdd..5eeff2ed845 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1688,18 +1688,11 @@ public static function intersect(Type ...$types): Type && get_class($types[$i]) === ArrayType::class && $types[$j] instanceof CallableType ) { - $existingValueType = $types[$i]->getItemType(); - $offset0ValueType = self::intersect($existingValueType, new UnionType([new ClassStringType(), new ObjectWithoutClassType()])); - $offset1ValueType = self::intersect($existingValueType, new StringType()); - if ($offset0ValueType instanceof NeverType || $offset1ValueType instanceof NeverType) { + $narrowed = self::narrowArrayTypeWithCallable($types[$i]); + if ($narrowed instanceof NeverType) { return new NeverType(); } - $types[$i] = new ConstantArrayType( - [new ConstantIntegerType(0), new ConstantIntegerType(1)], - [$offset0ValueType, $offset1ValueType], - [2], - isList: TrinaryLogic::createYes(), - ); + $types[$i] = $narrowed; continue; } @@ -1708,18 +1701,11 @@ public static function intersect(Type ...$types): Type && get_class($types[$j]) === ArrayType::class && $types[$i] instanceof CallableType ) { - $existingValueType = $types[$j]->getItemType(); - $offset0ValueType = self::intersect($existingValueType, new UnionType([new ClassStringType(), new ObjectWithoutClassType()])); - $offset1ValueType = self::intersect($existingValueType, new StringType()); - if ($offset0ValueType instanceof NeverType || $offset1ValueType instanceof NeverType) { + $narrowed = self::narrowArrayTypeWithCallable($types[$j]); + if ($narrowed instanceof NeverType) { return new NeverType(); } - $types[$j] = new ConstantArrayType( - [new ConstantIntegerType(0), new ConstantIntegerType(1)], - [$offset0ValueType, $offset1ValueType], - [2], - isList: TrinaryLogic::createYes(), - ); + $types[$j] = $narrowed; continue; } @@ -1841,6 +1827,27 @@ public static function removeTruthy(Type $type): Type return self::remove($type, StaticTypeFactory::truthy()); } + private static function narrowArrayTypeWithCallable(ArrayType $arrayType): Type + { + if ($arrayType->getKeyType()->isSuperTypeOf(new IntegerType())->no()) { + return new NeverType(); + } + + $existingValueType = $arrayType->getItemType(); + $offset0ValueType = self::intersect($existingValueType, new UnionType([new ClassStringType(), new ObjectWithoutClassType()])); + $offset1ValueType = self::intersect($existingValueType, new StringType()); + if ($offset0ValueType instanceof NeverType || $offset1ValueType instanceof NeverType) { + return new NeverType(); + } + + return new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantIntegerType(1)], + [$offset0ValueType, $offset1ValueType], + [2], + isList: TrinaryLogic::createYes(), + ); + } + private static function narrowConstantArrayWithCallable(ConstantArrayType $constantArray): Type { $keyTypes = $constantArray->getKeyTypes(); diff --git a/tests/PHPStan/Analyser/nsrt/bug-14549.php b/tests/PHPStan/Analyser/nsrt/bug-14549.php index ad95b7ac63d..99e3b2a6cf1 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14549.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14549.php @@ -52,6 +52,14 @@ public function testTypedArrayNarrowing(array $task): void } } + /** @param array $task */ + public function testStringKeyedArrayNarrowing(array $task): void + { + if (\is_callable($task)) { + assertType('*NEVER*', $task); + } + } + /** @param callable-array $value */ public function testCallableArrayPhpDoc(array $value): void {