Skip to content

Narrow array&callable intersection to list{class-string|object, string}#5563

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

Narrow array&callable intersection to list{class-string|object, string}#5563
phpstan-bot wants to merge 5 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-rrmiaik

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

Fixes phpstan/phpstan#14549

When is_callable() narrows an array to array&callable, the resulting type was too permissive — it didn't reflect that callable arrays in PHP are always [class-string|object, string] (a class/object and method name pair).

This PR adds narrowing logic in TypeCombinator::intersect() so that when a plain ArrayType is intersected with CallableType, it's replaced with a ConstantArrayType representing list{class-string|object, string}. This ensures:

  • The narrowed type displays as list{class-string|object, string}&callable(): mixed
  • Iterating over it yields 0|1 keys and object|string values
  • Passing it to a method expecting array<int> correctly reports a type error
  • Typed arrays (e.g. array<string>) are narrowed with intersection against the callable shape

Changes

  • src/Type/TypeCombinator.php: Added narrowing logic in intersect() for ArrayType + CallableType combinations
  • tests/PHPStan/Analyser/nsrt/bug-14549.php: New regression test covering plain array, typed array, constant array, and callable-array PHPDoc narrowing
  • tests/PHPStan/Rules/Methods/data/bug-14549.php: New rule test data verifying parameter type error is reported
  • tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php: Added testBug14549()
  • tests/PHPStan/Analyser/nsrt/bug-3842.php: Updated assertion to match new narrowed type
  • tests/PHPStan/Analyser/nsrt/bug-12393.php and bug-12393b.php: Updated callable-to-array-property assertion (arrayarray<mixed>)
  • phpstan-baseline.neon: Updated instanceof counts for ArrayType and CallableType

Test plan

  • make tests passes (11942 tests)
  • make phpstan passes (no errors)
  • make cs-fix passes (no violations)
  • make name-collision has a pre-existing failure unrelated to this change

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<int> is expected

Fixes phpstan/phpstan#14549
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

This PR should be able to remove the special behavior added in the InterdectionType hasOffsetValue and getOffsetvalue

{
if (\is_callable($task)) {
// ConstantArrayType keeps its shape, callable narrows offset access
assertType('list{string, string}&callable(): mixed', $task);
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.

This should be narrowed to list{class-string, string}&callabld

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.

All changes have been pushed. Here's a summary of what was done to address the review:

  1. Added ConstantArrayType + CallableType narrowing in TypeCombinator::intersect() — When a ConstantArrayType (e.g. array{string, string}) is intersected with CallableType, the value types at offsets 0 and 1 are now narrowed (offset 0 intersected with class-string|object, offset 1 with string). This means array{string, string}&callable now becomes list{class-string, string}&callable.

  2. Updated callable-array PHPDoc resolution in TypeNodeResolver to use TypeCombinator::intersect() instead of directly constructing an IntersectionType. This makes callable-array resolve to list{class-string|object, string}&callable(): mixed instead of the un-narrowed array&callable(): mixed.

  3. Removed the special hasOffsetValueType/getOffsetValueType callable logic from IntersectionType — This is no longer needed since the narrowing now happens at the TypeCombinator level, producing a properly-typed ConstantArrayType upfront.

  4. Updated test assertions in bug-14549.php, bug-3842.php, and more-types.php to match the new narrowed types.

All tests (11942) pass and make phpstan reports no errors.

/** @param callable-array $value */
public function testCallableArrayPhpDoc(array $value): void
{
assertType('array&callable(): mixed', $value);
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.

This should be narrowed.

Callable-array parsing could be improved

…ecial 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 <noreply@anthropic.com>
…callable

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 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. The fix changes narrowConstantArrayWithCallable to first locate both offset 0 and offset 1 in the array, and only narrow their value types if both are present. This addresses staabm's concern — a callable array requires both elements, so narrowing one in isolation without the other doesn't make sense.

$valueTypes = $constantArray->getValueTypes();
$newValueTypes = $valueTypes;

foreach ($keyTypes as $k => $keyType) {
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 might want to immediately return NeverType when we have less than 2 key-types, so we don't create new ConstantArrayType for "too small/empty" constant arrays

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 adds an early NeverType return in narrowConstantArrayWithCallable when the constant array has fewer than 2 keys or doesn't contain both offsets 0 and 1 — avoiding unnecessary ConstantArrayType construction for arrays that can't be callable. All tests pass and phpstan reports no errors.

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 <noreply@anthropic.com>
Comment thread src/Type/TypeCombinator.php Outdated
$types[$i] = new ConstantArrayType(
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
[$offset0ValueType, $offset1ValueType],
[2],
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.

do we really know at this point that $types[$i] did not have more offsets than 0/1?

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 what was done to address staabm's review:

The concern: For the ArrayType + CallableType narrowing, does the code correctly account for all type information when replacing an ArrayType with a 2-element ConstantArrayType?

The answer: A plain ArrayType doesn't have specific offsets (that's ConstantArrayType), but it does carry a key type. Since callable arrays always require integer keys 0 and 1, the original code wasn't checking whether the key type was compatible with integers. For example, array<string, mixed> & callable should be never because string keys are incompatible with the integer keys a callable array requires.

Changes:

  1. Extracted narrowArrayTypeWithCallable() — moved the inline narrowing logic into a dedicated method (mirroring narrowConstantArrayWithCallable) for clarity
  2. Added key type check — verifies $arrayType->getKeyType()->isSuperTypeOf(new IntegerType()) before narrowing; returns NeverType if the key type can't include integers
  3. Added testarray<string, mixed> + is_callable() now correctly resolves to *NEVER*

All 11,942 tests pass and make phpstan reports no errors.

@ondrejmirtes
Copy link
Copy Markdown
Member

Hey, this is a huge diff trying to fix a simple error. I'd just fix it in MissingTypehintCheck. "Do not report missing iterable value type if the type is a callable and an array."

A plain ArrayType carries a key type (e.g. array<string, mixed> 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 <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.

4 participants