Skip to content

Declared return type does not widen literal template arguments inside a closure with a body (unresolvedTemplateArguments) #15415

Open
phpstan/phpstan-src
#6819

Description

@SanderMuller

Bug report

With bleedingEdge on 2.3.0, a generic call inside a closure with a body keeps its literal template arguments. The declared return type does not widen them. In an arrow function, it does.

In the snippet, keyBy() infers TNewKey as 'negative'|'positive'. The method declares @return Coll<string, int>, and TKey is invariant:

  • direct() returns the call: no error.
  • arrowFunction() returns it through run(fn (): Coll => ...): no error.
  • blockClosure() returns it through run(function () use ($c): Coll { return ...; }): return.type, because the result is Coll<'negative'|'positive', int>.

There is no error without bleedingEdge, with featureToggles.unresolvedTemplateArguments: false, or on 2.2.17. It still happens on 2.3.x at f9a915969a9710d6e7f4c1ae3e9249b015115753.

Laravel's Collection has an invariant TKey too. I found this with keyBy() inside once(function () { ... }), and with a mapWithKeys() callback with a body that returns an inner collection.

Code snippet that reproduces the problem

https://phpstan.org/r/a58bbb26-3d4f-44a3-a9d3-6faf8973390b

Expected output

No error for blockClosure(), the same as for arrowFunction().

Activity

  1. SanderMuller commented on Oct 6, 2026

    @SanderMuller
    ContributorAuthor

    I think this comes from where template arguments are resolved. StatementsHandler::processBodyStmtNodesTwoPass() gives an Expr\Closure body its own two-pass resolution, but an arrow function is part of the enclosing body. So the marker that keyBy() creates inside the closure is resolved by the closure's own pass. That pass only sees the return to the raw Coll, not the outer @return Coll<string, int>, so it falls back to the literal keys.

    A related case shows the marker itself in an error, when the closure is called through a variable:

    https://phpstan.org/r/a53ff970-0411-4103-be2f-ad7590a155a8

    Method Test::closureInVariable() should return Coll<string, int> but returns Coll<unresolved('negative'|'positive'), int>.
    

    This is bleedingEdge only, on 2.3.0 and on 2.3.x at f9a915969. The class doc of UnresolvedTemplateArgumentType says rules never see it.

  2. added this to the 2.3.0 feedback milestone on Oct 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions