Skip to content

ArrayType::isCallable(): ask StringType::isSuperTypeOf() about the item type instead of only isString() - #6166

Merged
ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-8uk3h8a
Jul 31, 2026
Merged

ArrayType::isCallable(): ask StringType::isSuperTypeOf() about the item type instead of only isString()#6166
ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-8uk3h8a

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

callable&array<mixed>, callable&list<mixed> and callable-array were recognized but not enforced. As reported in the issue, PHPStan reported nothing at all for these — not a wrong method name, not [1, 2], not even 42 — while the shaped form callable&array{class-string|object, string} rejected all of them.

The reason is that the intersection collapsed to never before any rule saw it, and never is accepted by everything. This PR makes ArrayType::isCallable() answer correctly for item types that are supertypes of string, which keeps the intersection alive and restores the acceptance check in every position.

Changes

  • src/Type/ArrayType.phpisCallable() no longer derives its answer solely from $this->itemType->isString(). When that says No, it now also asks (new StringType())->isSuperTypeOf($this->itemType)->maybe() before concluding the array cannot be callable.

Positions that were silent before and now report (all share the RuleLevelHelper code path, so all are fixed by the one change; each was verified to be broken before the fix):

  • function arguments — tests/PHPStan/Rules/Functions/data/bug-13114.php
  • return statements — tests/PHPStan/Rules/Functions/data/bug-13114-return.php
  • property assignments — tests/PHPStan/Rules/Properties/data/bug-13114.php
  • method / static method / constructor / __invoke / callable-value arguments and parameter default values (manually verified, covered transitively by the same check)
  • values narrowed by is_callable() at level 10, where the narrowed array<mixed>&callable was previously unusable as an argument type

Analogous constructs probed and found already correct, so no test was kept for them:

  • callable&string / callable-string and callable&object / callable-object — these do not nest a mixed and never collapsed
  • callable&iterable, Traversable<mixed, mixed>&callable, array{mixed, mixed}&callable (ConstantArrayType::isCallable() returns Maybe via its "unknown method name" path)
  • every other zero-argument TrinaryLogic-returning Type method, compared for array<mixed>, list<mixed>, non-empty-array<mixed>, iterable<mixed>, array{mixed, mixed} and ArrayObject<mixed, mixed> against their strict-mixed rewrites — isCallable() was the only divergence
  • MissingTypehintCheck::getIterableTypesWithMissingValueTypehint() deliberately skips the array member of a callable array, which explains the missingType.iterableValue observation in the issue; that is intended and left alone

Root cause

StrictMixedType answers No to every isX() query (isString(), isObject(), isArray(), …) even though its isSuperTypeOf() / accepts() return Yes for every type. That is deliberate — it is how levels 9/10 force explicit narrowing — but it makes any nested StrictMixedType lie to container types that ask isX() about their item type.

ArrayType::isCallable() was such a container query:

return TrinaryLogic::createMaybe()->and($this->itemType->isString());

At levels 9 and 10, RuleLevelHelper::transformCommonType() (and transformAcceptedType()) rewrite nested mixed into StrictMixedType via TypeTraverser::map(). IntersectionType::traverse() rebuilds the intersection with TypeCombinator::intersect(), which asks CallableType::isSuperTypeOf(array<strict-mixed>); that delegates to ArrayType::isCallable(), got No, and intersect() returned NeverType.

The parameter/return/property type the rules then received was never, and never->accepts(anything) is Yes, so RuleLevelHelper::accepts() never produced an error. This is why the whole check disappeared rather than merely weakening — and why level 9 broke callable&array<mixed> (explicit mixed) while level 10 additionally broke callable-array (implicit mixed), with levels 0–8 behaving correctly.

Asking isSuperTypeOf() instead is the standard way to phrase "could this type overlap with string" and gives the right answer for StrictMixedType (Maybe) while preserving the previous No for array<int>, array<object>, array<never> and array<mixed~string>.

Test

  • CallToFunctionParametersRuleTest::testBug13114() runs the issue's playground sample plus zonuexe's probes ([new C, 'missing'], [1, 2], 42) over three RuleLevelHelper configurations — (explicitMixed, implicitMixed) = (false, false), (true, false), (true, true) — and asserts the same errors in all three. Data sets Nicer Markdown #1 and SoapClient - fixed signature map for SoapClient::__call #2 fail without the fix; #0 passes and locks in the levels-0–8 behaviour that already worked. The same data file covers callable&string / callable&object and the two is_callable()-narrowing cases.
  • ReturnTypeRuleTest::testBug13114()@return callable&array<mixed> with a bad return [new C, 'h'];. Silent before the fix.
  • TypesAssignedToPropertiesRuleTest::testBug13114()@var callable-array and @var callable&array<mixed> properties assigned [new C, 'h'] and 42. Silent before the fix.
  • TypeCombinatorTest::dataIntersect() — two cases pinning the type-level behaviour directly: intersect(array<strict-mixed>, callable) is non-empty-list<mixed>&callable(): mixed (was *NEVER*), and intersect(array<int>, callable) stays *NEVER*.

Fixes phpstan/phpstan#13114

…e item type instead of only `isString()`

* `ArrayType::isCallable()` returned `No` whenever the item type answered `isString()` with `No`. `StrictMixedType` answers `No` to every `isX()` query even though it is a supertype of everything, so `array<strict-mixed>` was declared non-callable.
* At levels 9/10 `RuleLevelHelper::transformCommonType()` / `transformAcceptedType()` rewrite nested `mixed` to `StrictMixedType` and `IntersectionType::traverse()` re-intersects the members, so `callable&array<mixed>`, `callable&list<mixed>` and `callable-array` collapsed to `never` — and `never` accepts everything, which silenced the whole acceptance check.
* `isCallable()` now falls back to `(new StringType())->isSuperTypeOf($this->itemType)->maybe()` when `isString()` says `No`, so supertypes-of-string stay in play while `array<int>`, `array<object>`, `array<never>` and `array<mixed~string>` keep answering `No` exactly as before.
* Fixes the reported argument position plus the analogous positions that go through the same `RuleLevelHelper` path and were equally silent: return statements, property assignments, method/static-method/constructor/`__invoke`/callable-value arguments, and arrays narrowed by `is_callable()`.
* Probed the sibling constructs and found them already correct: `callable&string` / `callable-string`, `callable&object` / `callable-object`, `callable&iterable`, `ConstantArrayType::isCallable()` for `array{mixed, mixed}`. Comparing every zero-argument `TrinaryLogic` method of `Type` for `array<mixed>` / `list<mixed>` / `non-empty-array<mixed>` / `iterable<mixed>` / `array{mixed, mixed}` / `ArrayObject<mixed, mixed>` against their strict-mixed counterparts showed `isCallable()` as the only divergence.
@ondrejmirtes
ondrejmirtes merged commit af929ab into phpstan:2.2.x Jul 31, 2026
742 of 745 checks passed
@ondrejmirtes
ondrejmirtes deleted the create-pull-request/patch-8uk3h8a branch July 31, 2026 15:59
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.

Fully support callable intersection types callable&array, callable&string, callable&object.

2 participants