Resolve generic parameter types from an array literal argument's skeleton before typing the closures nested in it - #6482
Merged
ondrejmirtes merged 1 commit intoSep 20, 2026
Conversation
…eton before typing the closures nested in it - `ArgumentsHandler::processArgs()` now pins a structural skeleton for an array-literal argument that holds closures, before the argument is walked, so the per-argument acceptor resolution can infer the callee's templates from the array's own keys and non-closure values instead of padding the whole argument with `mixed` and falling back to the templates' bounds. - New `ArgumentsHandler::gatherArrayArgTypeSkeleton()` builds that skeleton without walking anything: nested array literals recurse, closures/arrow functions contribute `ClosureTypeResolver::getDeclaredClosureType()`, and every other key/value is priced from scope state, then from `InitializerExprTypeResolver` (literals, `::class`, constants, concatenation), and finally `mixed`. A `mixed` slot can never resolve a template below its bound, so the skeleton only ever sharpens the resolution. - The skeleton never reaches `$gatheredTypes`; the argument's real type replaces it once the walk is done, so the call's return type and the reported argument type are unchanged. - The same code path covers the analogous cases, each with its own assertion in the regression test: templates pinned by a sibling *value* of the array (in either order), nested array literals, `callable(T)` instead of `Closure(T)`, `class-string<T>` keys, variadic array parameters, named arguments, and methods / static methods / constructors.
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
When a closure is passed inside an array literal argument and the array's own
keys (or its non-closure values) are what decide the callee's template, the
closure's parameters were typed from the template's bound instead of the
resolved type:
The fix pins a walk-free structural skeleton of the array literal before the
argument is processed, so the template resolution that types the nested
closures sees the array's keys and values.
Changes
src/Analyser/ArgumentsHandler.phpprocessArgs(): for an argument that is an array literal containing aclosure/arrow function (and when the acceptor selection is type-driven),
$gatheredArgTypeByIndex[$i]is now seeded with a skeleton of that arrayinstead of being left absent — the padded, per-argument acceptor
resolution right below reads it and resolves the callee's templates from
it. The subsequent real walk overwrites the entry with the argument's
actual type, so
$gatheredTypes, the call's return type and the reportedargument type are untouched.
gatherArrayArgTypeSkeleton(): builds the skeleton throughInitializerExprTypeResolver::getArrayType()with a type getter thatrecurses into nested array literals, answers closures/arrow functions with
ClosureTypeResolver::getDeclaredClosureType()(declared signature, nobody walk), and otherwise prices the expression from scope state
(
NodeScopeResolver::findScopeStateType()), then fromInitializerExprTypeResolver::getType()(literals,::class, constants,concatenation, …), and finally
mixed.InitializerExprTypeResolveris injected for this.Analogous cases probed; all were broken by the same root cause and are fixed by
the same code path (each has its own assertion in the regression test):
in both orders (
[$value, $closure]and[$closure, $value])array<string, array<T, \Closure(T): string>>)callable(T): stringinstead of\Closure(T): stringclass-string<T>keys (array<class-string<T>, \Closure(T): void>)processArgs()is shared,so one fix covers them)
array{\Closure(T): U, T}→
U)Probed and found already correct, so left alone: a closure passed as its own
argument alongside the argument that pins the template
(
f(['a', 'b'], function ($k) { ... })) — the existing closures-last argumentordering plus
gatherClosureArgType()already handles that shape.Root cause
ArgumentsHandler::processArgs()resolves the callee's parameters per argumentfrom the argument types gathered so far, padding the not-yet-walked arguments
with
mixed. A closure argument gets around the ordering problem twice: it issorted last, and
gatherClosureArgType()pins its declaration-faithful typebefore its body is walked.
An array literal has neither escape hatch. It sorts as a non-closure, and it is
the very argument being resolved, so its padded entry is
mixed— no templatecan be inferred from it.
array<TColumn, \Closure(TColumn): string>thereforestays generic,
ResolvedFunctionVariantfalls back toTColumn's bound(
string), andArrayHandlerhands that bound down to the item'sExpressionContext::enterPassedToType(), which is what types the nestedclosure's parameters. The array's keys and non-closure values — which are
structurally known and need no walk — were simply never consulted.
The fix gives the array literal the same treatment the top-level closure
argument already had: a cheap, declaration-only stand-in pinned before the walk.
Widening an unknown slot to
mixedis safe becauseTemplateTypeTrait::inferTemplateTypes()only accepts a received type its boundis a supertype of, so a
mixedslot leaves the template at its bound ratherthan widening it.
Test
tests/PHPStan/Analyser/nsrt/bug-15269.php— an NSRT type-inference fixture.It opens with the issue's playground reproducer (both the
functionand thefnformatter now assert'email'|'name'), then covers every analogous caselisted above with its own
assertType().It also keeps one non-regression assertion: when the sibling slot cannot be
priced without a walk (
boundedUnknown([unknownString(), function ($x) { ... }])),the template must stay at its
stringbound rather than being widened tomixedby the skeleton.Verified failing before the fix (14 of the 15 assertions report the old
bound/
mixedtypes) and passing after.make tests,make phpstanandmake cs-fixare green.Fixes phpstan/phpstan#15269