Skip to content

Return never when a narrowing removes the whole bound of a template type - #7071

Open
zonuexe wants to merge 1 commit into
phpstan:2.3.xfrom
zonuexe:template-never-bound
Open

zonuexe wants to merge 1 commit into
phpstan:2.3.xfrom
zonuexe:template-never-bound

Conversation

@zonuexe

@zonuexe zonuexe commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

When a narrowing removes every value a template's bound allows, TemplateTypeTrait::subtract() and tryRemove() pass a never bound to TemplateTypeFactory::create(). The factory has no case for it and falls back to a mixed bound, so a branch that cannot be reached holds a mixed-bound T, and merging that branch back widens the variable for the rest of the function. This PR returns never from both methods instead.

Impact

Expression Before After
T of int $t, in the else of if ($t >= 0) inside if ($t > 0) T of mixed~(bool|float<0.0, max>|null) *NEVER*
same, after that if T of mixed~(…) T of int<1, max>
T of int $t, in the else of if ($t < 0) … elseif ($t >= 0) T of mixed~(…) *NEVER*
T of int $t, after if ($t < 0) {}, inside if ($t >= 2 && $t <= 2) T of mixed~(…) T of 2
T of bool $t, in the else of if ($t === true) … elseif ($t === false) T *NEVER*
  • Returning $t afterwards was reported as should return T of int but returns T of mixed, or should return T of bool but returns T.
  • Comparisons on a template are undecided, so PHPStan keeps the branch that cannot be reached instead of pruning it. Its type is what leaks into the merge.
  • The same happens for a T of float now that Keep a narrowed float bound when creating a template type #7070 keeps a narrowed float bound.

Fix

subtract() and tryRemove() now return the never that TypeCombinator::remove() gives for the bound. If no value of the bound is left, no value of T is left either. The native TemplateTypeTrait in turbo-ext gets the same change.

Testing

  • tests/PHPStan/Analyser/nsrt/template-never-bound.php covers the cases in the table and a T of string checked by is_string().
  • ReturnTypeRuleTest::testTemplateNeverBound() runs the same functions:
    • The false positives are gone.
    • Returning an unrelated int where T is expected is still reported.
  • The turbo differential test already removes mixed from a T with a mixed bound, so it sees the change. With the PHP twin at the base, smoke.php reports the native *NEVER* as a mismatch.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@zonuexe
zonuexe force-pushed the template-never-bound branch from 483222f to 70abdae Compare November 14, 2026 16:25
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