Skip to content

Fix callable array intersection type offset narrowing#5548

Open
phpstan-bot wants to merge 5 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-ty6mco0
Open

Fix callable array intersection type offset narrowing#5548
phpstan-bot wants to merge 5 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-ty6mco0

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

Fixes phpstan/phpstan#3842

When is_array() narrows a callable parameter, the resulting type is array<mixed, mixed>&callable(): mixed. Previously, accessing offsets on this intersection type returned mixed for all offsets, causing false positives when passing the value to functions expecting array{string|object, string} or array{class-string|object, string}.

This PR teaches IntersectionType that when an intersection is both callable and an array (i.e., a callable array), offsets 0 and 1 are guaranteed to exist with specific types:

  • Offset 0: class-string|object (the class or object the method belongs to)
  • Offset 1: string (the method name)

Changes

  • src/Type/IntersectionType.php: Enhanced hasOffsetValueType() to return Yes for offsets 0 and 1 on callable array intersections. Enhanced getOffsetValueType() to narrow the result type for those offsets.
  • Tests: Added regression tests for both type inference (NSRT) and rule-level acceptance checking.

Test plan

  • New NSRT test verifies $value[0] is class-string|object and $value[1] is string after is_array() narrowing on a callable
  • New NSRT test verifies the same for callable-array PHPDoc type
  • New rule test verifies no false positive when passing callable array to array{string|object, string} parameter
  • Full test suite passes (11927 tests)
  • PHPStan self-analysis passes
  • Coding standards check passes

…rsections

When is_array() narrows a callable parameter, the resulting
array&callable intersection type now correctly reports that offset 0
is class-string|object and offset 1 is string. This fixes false
positives when passing the narrowed value to functions expecting
array{string|object, string} or similar callable-array shapes.
Comment thread src/Type/IntersectionType.php Outdated
if (!$result->yes() && $this->isCallable()->yes() && $this->isArray()->yes()) {
$arrayKeyOffsetType = $offsetType->toArrayKey();
if (
(new ConstantIntegerType(0))->isSuperTypeOf($arrayKeyOffsetType)->yes()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(new ConstantIntegerType(0))->isSuperTypeOf($arrayKeyOffsetType)->yes() || (new ConstantIntegerType(1))->isSuperTypeOf($arrayKeyOffsetType)->yes()

Should be rework into an UnionType->isSuperTypeOf check.

Cause we could look for the offset 0|1, which always exists.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed. Here's a summary of the changes addressing the review:

  1. hasOffsetValueType: Replaced the two separate ConstantIntegerType(0)->isSuperTypeOf(...) || ConstantIntegerType(1)->isSuperTypeOf(...) checks with a single UnionType([0, 1])->isSuperTypeOf(...) check. This correctly handles the case where the offset itself is 0|1.

  2. getOffsetValueType: Instead of an if/elseif that only matched exact offsets, the code now:

    • Guards with the same UnionType([0, 1])->isSuperTypeOf(...) check
    • Checks which concrete offsets are possible within the offset type (using reversed isSuperTypeOf)
    • Unions the applicable narrowed types — so offset 0|1 correctly narrows to object|string
  3. Test: Added a test case for 0|1 offset access asserting the result is object|string.

All 11927 tests pass and PHPStan self-analysis reports no errors.

Comment thread src/Type/IntersectionType.php Outdated
Comment on lines +943 to +945
if ((new ConstantIntegerType(0))->isSuperTypeOf($arrayKeyOffsetType)->yes()) {
$result = TypeCombinator::intersect($result, TypeCombinator::union(new ClassStringType(), new ObjectWithoutClassType()));
} elseif ((new ConstantIntegerType(1))->isSuperTypeOf($arrayKeyOffsetType)->yes()) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if I access to the offset 0|1 ?
This could be narrowed to object|string.

Address review feedback: use a single UnionType(0|1)->isSuperTypeOf check
instead of separate ConstantIntegerType checks, and handle the case where
the offset is itself a union (e.g. 0|1) by narrowing to the union of both
offset types (object|string).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment thread src/Type/IntersectionType.php Outdated
if ($callableArrayOffsetType->isSuperTypeOf($arrayKeyOffsetType)->yes()) {
$narrowedTypes = [];
if (!$arrayKeyOffsetType->isSuperTypeOf(new ConstantIntegerType(0))->no()) {
$narrowedTypes[] = TypeCombinator::union(new ClassStringType(), new ObjectWithoutClassType());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new UnionType can be used here

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Replaced TypeCombinator::union(new ClassStringType(), new ObjectWithoutClassType()) with new UnionType([new ClassStringType(), new ObjectWithoutClassType()]) as the reviewer suggested — since these types are known and won't simplify, using UnionType directly avoids unnecessary normalization overhead. All tests pass and PHPStan self-analysis is clean.

Comment thread src/Type/IntersectionType.php Outdated
$narrowedTypes[] = new StringType();
}
if ($narrowedTypes !== []) {
$result = TypeCombinator::intersect($result, TypeCombinator::union(...$narrowedTypes));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could save the TypeCombinator::union

if (0 is superType) we narrow new Union (new ClassStringType(), new ObjectWithoutClassType())

elseif (1 is superType) we narrow String

else we narrow new Union (new StringType(), new ObjectWithoutClassType())

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed. The change simplifies getOffsetValueType() by replacing the array-building + TypeCombinator::union pattern with a direct if/elseif/else that handles each case:

  • Offset 0class-string|object
  • Offset 1string
  • Offset 0|1object|string

All 11927 tests pass, PHPStan self-analysis and coding standards are clean.

phpstan-bot and others added 2 commits April 27, 2026 16:27
…ngType|ObjectWithoutClassType

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use direct UnionType construction instead of building an array and
calling TypeCombinator::union, as suggested in review.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@@ -0,0 +1,29 @@
<?php declare(strict_types = 1);

namespace Bug3842;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merge the nsrt and data file in a single one which is loaded for both nsrt and rule tests.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The change merges the two test files into a single shared one:

  • Deleted tests/PHPStan/Rules/Functions/data/bug-3842.php (duplicate data file)
  • Updated CallToFunctionParametersRuleTest::testBug3842() to reference tests/PHPStan/Analyser/nsrt/bug-3842.php instead
  • The nsrt file already contained all the test scenarios (including check() and checkClassString() calls), so no content changes were needed

All 11927 tests pass and PHPStan self-analysis is clean.

The rule test now references the nsrt test file directly instead of
maintaining a separate duplicate data file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants