Skip to content

Resolve generic parameter types from an array literal argument's skeleton before typing the closures nested in it - #6482

Merged
ondrejmirtes merged 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-683qpa2
Sep 20, 2026
Merged

ondrejmirtes merged 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-683qpa2

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

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:

/**
 * @template TColumn of string
 * @param array<TColumn, \Closure(TColumn): string> $formatters
 */
function formatColumns(array $formatters): void {}

formatColumns([
    'name' => function ($column) {
        \PHPStan\dumpType($column); // was: string, now: 'email'|'name'
        return strtoupper($column);
    },
    'email' => fn ($column) => \PHPStan\dumpType($column), // same
]);

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.php
    • processArgs(): for an argument that is an array literal containing a
      closure/arrow function (and when the acceptor selection is type-driven),
      $gatheredArgTypeByIndex[$i] is now seeded with a skeleton of that array
      instead 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 reported
      argument type are untouched.
    • New gatherArrayArgTypeSkeleton(): builds the skeleton through
      InitializerExprTypeResolver::getArrayType() with a type getter that
      recurses into nested array literals, answers closures/arrow functions with
      ClosureTypeResolver::getDeclaredClosureType() (declared signature, no
      body walk), and otherwise prices the expression from scope state
      (NodeScopeResolver::findScopeStateType()), then from
      InitializerExprTypeResolver::getType() (literals, ::class, constants,
      concatenation, …), and finally mixed.
    • InitializerExprTypeResolver is 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):

  • the template pinned by a sibling value of the array rather than its key,
    in both orders ([$value, $closure] and [$closure, $value])
  • nested array literals (array<string, array<T, \Closure(T): string>>)
  • callable(T): string instead of \Closure(T): string
  • class-string<T> keys (array<class-string<T>, \Closure(T): void>)
  • variadic array parameters
  • named arguments
  • method calls, static method calls and constructors (processArgs() is shared,
    so one fix covers them)
  • a template whose value contributes to the return type (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 argument
ordering plus gatherClosureArgType() already handles that shape.

Root cause

ArgumentsHandler::processArgs() resolves the callee's parameters per argument
from the argument types gathered so far, padding the not-yet-walked arguments
with mixed. A closure argument gets around the ordering problem twice: it is
sorted last, and gatherClosureArgType() pins its declaration-faithful type
before 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 template
can be inferred from it. array<TColumn, \Closure(TColumn): string> therefore
stays generic, ResolvedFunctionVariant falls back to TColumn's bound
(string), and ArrayHandler hands that bound down to the item's
ExpressionContext::enterPassedToType(), which is what types the nested
closure'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 mixed is safe because
TemplateTypeTrait::inferTemplateTypes() only accepts a received type its bound
is a supertype of, so a mixed slot leaves the template at its bound rather
than 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 function and the
fn formatter now assert 'email'|'name'), then covers every analogous case
listed 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 string bound rather than being widened to
mixed by the skeleton.

Verified failing before the fix (14 of the 15 assertions report the old
bound/mixed types) and passing after. make tests, make phpstan and
make cs-fix are green.

Fixes phpstan/phpstan#15269

…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.
@ondrejmirtes
ondrejmirtes merged commit 7a8c43c into phpstan:2.3.x Sep 20, 2026
828 of 893 checks passed
@ondrejmirtes
ondrejmirtes deleted the create-pull-request/patch-683qpa2 branch September 20, 2026 08:07
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.

Closure parameters in an array argument are not inferred from generic array keys

2 participants