Skip to content

Keep a narrowed float bound when creating a template type - #7070

Merged
ondrejmirtes merged 2 commits into
phpstan:2.3.xfrom
zonuexe:template-float-narrowed-bound
Nov 14, 2026
Merged

ondrejmirtes merged 2 commits into
phpstan:2.3.xfrom
zonuexe:template-float-narrowed-bound

Conversation

@zonuexe

@zonuexe zonuexe commented Nov 14, 2026

Copy link
Copy Markdown
Contributor

TemplateTypeFactory::create() accepts a float bound only when its class is exactly FloatType, so a narrowed bound falls through to the last case and the template becomes a TemplateMixedType with a mixed bound. PHPStan then no longer accepts the value as T of float. This PR accepts every float subtype as a float bound, as #5905 did for integer ranges, enum cases and constant booleans.

Impact

Expression, T of float Before After
$t after if ($t > 0.0) T T of float<5.0E-324, max>
$t after if (is_nan($t)) T T of NAN
$t in the false branch of is_nan($t) T T of float<min, max>
$t after if ($t >= 1.5 && $t <= 1.5) T of mixed~(…) T of 1.5
$t after $t >= 0.0 ? … : … T T of float<0.0, max>|(T of NAN|float<min, -5.0E-324>)
abs($t) T of float float
  • Returning the narrowed value was reported as Function positive() should return T of float but returns T. A T of int narrowed the same way comes out as T of int<1, max> since Preserve template bound for enum-case, integer-range and constant-bool subtypes in TemplateTypeFactory::create() #5905, which added the IntegerRangeType case.
  • The damage was not limited to returning inside the branch: after a ternary on $t the merged type was a mixed-bound T, and a T of float|int narrowed to a float range broke the same way.
  • A narrowing reaches this when it leaves a single range or constant. A union such as NAN|float<min, 0.0>, the false branch of $t > 0.0, already became a TemplateUnionType.
  • An explicit bound in PHPDoc takes the same path. @template T of float<0, 1> and @template T of 1.5 used to be degraded to a mixed bound and are now kept, so a call to such a function returns the argument type. generics.notSupportedBound still reports both, as it reports T of int<1, max>.

abs() on a float template was typed as the template itself, because TemplateFloatType inherited return $this from FloatType::toAbsoluteNumber(). That was already wrong for T of float, and with a kept bound it became a definite range on the wrong side of zero: abs($t) in the true branch of $t < 0.0 was T of float<min, -5.0E-324>, so return abs($t); in a function returning T was accepted. TemplateFloatType::toAbsoluteNumber() now takes the absolute value of the bound, as TemplateIntegerType does through IntegerType::toAbsoluteNumber().

Cause

The float case accepts a bound whose class is exactly FloatType, or a TemplateFloatType. Narrowing produces the two other float subtypes, FloatRangeType and ConstantFloatType, and both fail the check. The integer case next to it accepts IntegerRangeType explicitly and sends a ConstantIntegerType to its own TemplateConstantIntegerType. Floats have no TemplateConstantFloatType, and TemplateFloatType holds any FloatType as its bound, so both subtypes belong in the float case.

Fix

The float case now accepts any FloatType, the way the object case accepts any ObjectType and for the same reason, which its comment states: a subtype without a dedicated Template* class keeps its precise bound instead of being widened to TemplateMixedType. A constant bound takes the route the boolean case already takes for a ConstantBooleanType, for which there is no TemplateConstantBooleanType either. The native TemplateTypeFactory and TemplateFloatType in turbo-ext get the same changes.

Not changed

These behave the same for T of int before and after this PR:

  • A narrowing that removes the whole bound passes a never bound to the factory, which still becomes a mixed-bound template. if ($t < 0.0) {} followed by if ($t >= 1.5 && $t <= 1.5) therefore still ends in T of mixed~(…).
  • float<5.0E-324, max> is only maybe a supertype of T of float<5.0E-324, max>, so passing the narrowed value to a parameter of exactly that range is still reported.

Testing

  • tests/PHPStan/Analyser/nsrt/bug-15714.php covers:
    • the cases of the issue, next to the T of int ones it compares them with;
    • a constant bound, the type after a ternary, and a T of float|int bound;
    • explicit range and constant bounds in PHPDoc;
    • abs() for float and integer templates.
  • ReturnTypeRuleTest::testBug15714() runs functions based on the issue's reproducer and a few more:
    • The false positives are gone.
    • Returning an unrelated float where T is expected is still reported.
    • return abs($t); is now reported.
  • turbo-ext/tests/type-family.php gets range and constant float bounds and an observation of toAbsoluteNumber(), so the differential test covers both native changes.

Closes phpstan/phpstan#15714

zonuexe and others added 2 commits November 14, 2026 17:34
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@ondrejmirtes
ondrejmirtes merged commit ca825b0 into phpstan:2.3.x Nov 14, 2026
897 of 906 checks passed
@ondrejmirtes

Copy link
Copy Markdown
Member

Thank you!

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.

Narrowing a T of float value reports "should return T of float but returns T"

2 participants