Skip to content

Generalize an inferred template type by what a conditional branch knows about it - #6607

Closed
SanderMuller wants to merge 2 commits into
phpstan:2.3.xfrom
SanderMuller:narrowed-subject-generalization
Closed

SanderMuller wants to merge 2 commits into
phpstan:2.3.xfrom
SanderMuller:narrowed-subject-generalization

Conversation

@SanderMuller

@SanderMuller SanderMuller commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

laravel/framework's type tests fail in the integration suite since 2.2.15, on 2.2.x and 2.3.x. The job named "Integration - laravel/framework" appears twice in the checks. The laravel.neon run passes and the laravel-types.neon run fails, so it can look like a flaky job. It is not: I get the same 6 errors on every run. At the pinned laravel commit, the 2.2.14 phar reports 0 errors, 2.2.15 reports 7 and 2.2.16 reports 6.

types/Support/Collection.php:546:Expected type Illuminate\Support\Collection<'foo', User>, actual: Illuminate\Support\Collection<string, User>

It bisects to 91dcf7e from #6480. A reduced version of laravel's keyBy():

/**
 * @template TNewKey of array-key|\UnitEnum
 * @param callable(TValue, TKey): TNewKey $keyBy
 * @return static<(TNewKey is \UnitEnum ? array-key : TNewKey), TValue>
 */
public function keyBy($keyBy)

$collection->keyBy(fn ($user) => 'foo'); // Collection<'foo', User> on 2.2.14, Collection<string, User> since 2.2.15

Since #6480 the else branch holds a NarrowedSubjectType for TNewKey. resolveResolvableTemplateTypes() traverses into it and generalizes the inferred 'foo' against TNewKey's own bound, array-key|\UnitEnum. That bound is not scalar, so generalizeInferredTemplateType() widens 'foo' to string. In 2.2.14 the branch held TNewKey ~ \UnitEnum, with bound int|string, and the same function kept 'foo'.

The fix resolves the subject inside a NarrowedSubjectType without generalizing it. It then generalizes the narrowed result against the reference, whose bound and variance are the narrowed ones.

The resolver is now a private method, not a closure. A closure that calls itself through use (&$resolve) is a reference cycle, and under gc_disable() it leaked 20 MB on the bug-13352 bench. As a method, the bench peaks at 187.3 MB, the same as 2.2.x. The time is within noise: 1.485s and 1.487s, against 1.49s to 1.52s on 2.2.x.

Only the subject is kept as inferred. A template type in the target, as in (T is U ? T : int), is still generalized, because the condition compares the generalized types. Without that, 'foo' & 'bar' narrowed to never and the call reported an unresolvable return type.

  • On 2.2.x, the new nsrt test failed without the change on five assertions, the three literal keys and the two template targets, and passed with it. The enum assertions pass either way.
  • make tests, make phpstan and phpcs are green.
  • laravel's laravel-types.neon analysis at the pinned commit goes from 6 errors to 0.
  • On 80 further dumpType() cases the result equals 2.2.14 in 74. Three of the other 6 equal 2.2.16. The last 3 are more precise than both releases. For example, (T is 'foo' ? T : int) called with 'bar' now gives int, where 2.2.14 gave 'bar'|int and 2.2.16 'foo'|int.

Rebased onto 2.3.x

This PR now targets 2.3.x. On 2.3.x the laravel laravel-types.neon analysis still reports the same 6 errors, and with this change it reports 0, with and without the turbo extension.

  • The change is ported to turbo-ext/src/ResolvedFunctionVariantWithOriginal.cpp, with a bump commit. The smoke test, side-by-side.php, signature-parity.php and the strict build pass, and the full suite passes with and without the extension. The Tempest output is byte-identical with the extension on and off, and identical to 2.3.x.
  • 2.3.x passes the inferred template arguments of a call through unresolvedOrResolvedTemplateArgument() when featureToggles.unresolvedTemplateArguments is on. Every template type except the kept subject still goes through it.
  • That toggle is on in bleedingEdge, which the nsrt tests load, and there 2.3.x already gives the expected types. So the test moved to NarrowedSubjectInferredLiteralTest, which runs without bleedingEdge. It fails without the change on 7 of 12 assertions, and passes with it. It also covers a keyBy() inherited from an interface through {@inheritDoc}, as in laravel.

The other caller of generalizeInferredTemplateType(), GenericTypeTemplateTraverser, skips a NarrowedSubjectType the same way. It maps only a class's own template types for new, and it returns a default or a bound without traversing into it. So I do not see how a NarrowedSubjectType could reach it, and I left it alone. I checked this by reading the code, not with a test.

🤖 Generated with Claude Code

@SanderMuller
SanderMuller force-pushed the narrowed-subject-generalization branch from 435aada to e8528b0 Compare September 26, 2026 22:06
@SanderMuller
SanderMuller force-pushed the narrowed-subject-generalization branch from e8528b0 to e815f30 Compare October 5, 2026 15:41
@SanderMuller
SanderMuller changed the base branch from 2.2.x to 2.3.x October 5, 2026 15:41
SanderMuller and others added 2 commits October 31, 2026 13:23
…ws about it

In the else branch of `(TNewKey is \UnitEnum ? array-key : TNewKey)` with
TNewKey of array-key|\UnitEnum, the branch knows TNewKey is an array-key. Since
the branches hold NarrowedSubjectType references, the resolver traversed into
them and generalized the inferred type against the subject's own bound, which
is not scalar, so a literal key such as 'foo' became string. 2.2.14 kept 'foo'.

The resolver now resolves the subject inside a NarrowedSubjectType without
generalizing it, and generalizes the narrowed result against the reference, whose
bound is the narrowed one. A template type in the target stays generalized, as
the condition sees it: otherwise `(T is U ? T : int)` narrowed 'foo' & 'bar' to
never.

With featureToggles.unresolvedTemplateArguments (bleedingEdge) the inferred template
arguments are solved after the call instead of generalized here, so the test runs
without bleedingEdge: NarrowedSubjectInferredLiteralTest.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ondrejmirtes
ondrejmirtes force-pushed the narrowed-subject-generalization branch from 87bec04 to 220af56 Compare October 31, 2026 13:33
@ondrejmirtes

Copy link
Copy Markdown
Member

Laravel updated their tests.

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.

2 participants