Skip to content

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

Open
SanderMuller wants to merge 1 commit into
phpstan:2.2.xfrom
SanderMuller:narrowed-subject-generalization
Open

SanderMuller wants to merge 1 commit into
phpstan:2.2.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.

  • The new nsrt test fails without the change on five assertions, the three literal keys and the two template targets, and passes 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.

On 2.3.x, ResolvedFunctionVariantWithOriginal has a turbo mirror, so the merge-up needs the same change in C++.

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

…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.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@SanderMuller
SanderMuller force-pushed the narrowed-subject-generalization branch from 435aada to e8528b0 Compare September 26, 2026 22:06
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.

1 participant