ArrayType::isCallable(): ask StringType::isSuperTypeOf() about the item type instead of only isString() - #6166
Merged
ondrejmirtes merged 1 commit intoJul 31, 2026
Conversation
…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.
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
callable&array<mixed>,callable&list<mixed>andcallable-arraywere 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 even42— while the shaped formcallable&array{class-string|object, string}rejected all of them.The reason is that the intersection collapsed to
neverbefore any rule saw it, andneveris accepted by everything. This PR makesArrayType::isCallable()answer correctly for item types that are supertypes ofstring, which keeps the intersection alive and restores the acceptance check in every position.Changes
src/Type/ArrayType.php—isCallable()no longer derives its answer solely from$this->itemType->isString(). When that saysNo, 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
RuleLevelHelpercode path, so all are fixed by the one change; each was verified to be broken before the fix):tests/PHPStan/Rules/Functions/data/bug-13114.phpreturnstatements —tests/PHPStan/Rules/Functions/data/bug-13114-return.phptests/PHPStan/Rules/Properties/data/bug-13114.php__invoke/ callable-value arguments and parameter default values (manually verified, covered transitively by the same check)is_callable()at level 10, where the narrowedarray<mixed>&callablewas previously unusable as an argument typeAnalogous constructs probed and found already correct, so no test was kept for them:
callable&string/callable-stringandcallable&object/callable-object— these do not nest amixedand never collapsedcallable&iterable,Traversable<mixed, mixed>&callable,array{mixed, mixed}&callable(ConstantArrayType::isCallable()returnsMaybevia its "unknown method name" path)TrinaryLogic-returningTypemethod, compared forarray<mixed>,list<mixed>,non-empty-array<mixed>,iterable<mixed>,array{mixed, mixed}andArrayObject<mixed, mixed>against their strict-mixed rewrites —isCallable()was the only divergenceMissingTypehintCheck::getIterableTypesWithMissingValueTypehint()deliberately skips the array member of a callable array, which explains themissingType.iterableValueobservation in the issue; that is intended and left aloneRoot cause
StrictMixedTypeanswersNoto everyisX()query (isString(),isObject(),isArray(), …) even though itsisSuperTypeOf()/accepts()returnYesfor every type. That is deliberate — it is how levels 9/10 force explicit narrowing — but it makes any nestedStrictMixedTypelie to container types that askisX()about their item type.ArrayType::isCallable()was such a container query:At levels 9 and 10,
RuleLevelHelper::transformCommonType()(andtransformAcceptedType()) rewrite nestedmixedintoStrictMixedTypeviaTypeTraverser::map().IntersectionType::traverse()rebuilds the intersection withTypeCombinator::intersect(), which asksCallableType::isSuperTypeOf(array<strict-mixed>); that delegates toArrayType::isCallable(), gotNo, andintersect()returnedNeverType.The parameter/return/property type the rules then received was
never, andnever->accepts(anything)isYes, soRuleLevelHelper::accepts()never produced an error. This is why the whole check disappeared rather than merely weakening — and why level 9 brokecallable&array<mixed>(explicitmixed) while level 10 additionally brokecallable-array(implicitmixed), 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 forStrictMixedType(Maybe) while preserving the previousNoforarray<int>,array<object>,array<never>andarray<mixed~string>.Test
CallToFunctionParametersRuleTest::testBug13114()runs the issue's playground sample pluszonuexe's probes ([new C, 'missing'],[1, 2],42) over threeRuleLevelHelperconfigurations —(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 coverscallable&string/callable&objectand the twois_callable()-narrowing cases.ReturnTypeRuleTest::testBug13114()—@return callable&array<mixed>with a badreturn [new C, 'h'];. Silent before the fix.TypesAssignedToPropertiesRuleTest::testBug13114()—@var callable-arrayand@var callable&array<mixed>properties assigned[new C, 'h']and42. Silent before the fix.TypeCombinatorTest::dataIntersect()— two cases pinning the type-level behaviour directly:intersect(array<strict-mixed>, callable)isnon-empty-list<mixed>&callable(): mixed(was*NEVER*), andintersect(array<int>, callable)stays*NEVER*.Fixes phpstan/phpstan#13114