From b13462d2c0ae3555486e1fcf704731b11bba87fb Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:35:14 +0000 Subject: [PATCH] Treat a template type of `mixed` operand like `mixed` in arithmetic so the result stays a benevolent `(float|int)` - InitializerExprTypeResolver::resolveCommonMath() decided whether an operand is mixed via `TypeCombinator::union($left, $right) instanceof MixedType`. A TemplateMixedType does not absorb the other operand in a union (`T|int`), so `T + int` produced a strict `float|int` while `mixed + int` and `T + U` produced a benevolent `(float|int)`. The check now also looks at each operand directly. Affects +, -, *, /, <<, >> and their compound assignments / ++ / --. - In 2.3 the array_reduce() callback is typed from the callable(TReturn, TIn) parameter, so `$carry` is TReturn while gathering the closure type; `$carry + map($val)` then resolved TReturn to strict float|int, which the `int` return type rejected. - Mirrored the change in turbo-ext/src/InitializerExprTypeResolver.cpp. - Probed unary -/+ (strict float|int for both mixed and T, consistent), ** and % (already identical for mixed and T), and the `T + U` / array `+` paths (already go through `instanceof MixedType` on each operand). Co-Authored-By: Claude Opus 5.5 --- .../InitializerExprTypeResolver.php | 11 ++-- tests/PHPStan/Analyser/nsrt/bug-15388.php | 53 +++++++++++++++++++ .../Rules/Functions/ReturnTypeRuleTest.php | 7 +++ .../Rules/Functions/data/bug-15388.php | 21 ++++++++ turbo-ext/src/InitializerExprTypeResolver.cpp | 5 +- 5 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15388.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-15388.php diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index abf5384f2a4..097176485d0 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -2335,8 +2335,13 @@ private function resolveCommonMath(Expr\BinaryOp $expr, Type $leftType, Type $ri $leftNumberType = $leftType->toNumber(); $rightNumberType = $rightType->toNumber(); + // a template type of mixed does not absorb the other operand in the union + $typesMixed = $types instanceof MixedType + || $leftType instanceof MixedType + || $rightType instanceof MixedType; + if ( - !$types instanceof MixedType + !$typesMixed && ( $rightNumberType instanceof IntegerRangeType || $rightNumberType instanceof ConstantIntegerType @@ -2397,14 +2402,14 @@ private function resolveCommonMath(Expr\BinaryOp $expr, Type $leftType, Type $ri $resultType = TypeCombinator::union($leftNumberType, $rightNumberType); if ($expr instanceof Expr\BinaryOp\Div) { - if ($types instanceof MixedType || $resultType->isInteger()->yes()) { + if ($typesMixed || $resultType->isInteger()->yes()) { return new BenevolentUnionType([new IntegerType(), new FloatType()]); } return new UnionType([new IntegerType(), new FloatType()]); } - if ($types instanceof MixedType + if ($typesMixed || $leftType instanceof BenevolentUnionType || $rightType instanceof BenevolentUnionType ) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-15388.php b/tests/PHPStan/Analyser/nsrt/bug-15388.php new file mode 100644 index 00000000000..3187e30115a --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15388.php @@ -0,0 +1,53 @@ + $vals */ +function foo(array $vals): void { + assertType('(float|int)', array_reduce($vals, function($carry, $val) { + return $carry + map($val); + }, 0)); + assertType('(float|int)', array_reduce($vals, function($carry, $val) { + return $carry + $val; + }, 0)); + assertType('(float|int)', array_reduce($vals, function($carry, int $val) { + return $carry - $val; + }, 0)); + assertType('(float|int)', array_reduce($vals, function($carry, int $val) { + return $carry * $val; + }, 1)); +} + +function map(int $val): int { + return $val; +} + +/** + * @template T + * @param T $t + * @param mixed $m + */ +function templateOfMixed($t, $m, int $i): void +{ + assertType('(float|int)', $m + $i); + assertType('(float|int)', $t + $i); + assertType('(float|int)', $i + $t); + assertType('(float|int)', $t + 1); + assertType('(float|int)', $t - $i); + assertType('(float|int)', $t - 1); + assertType('(float|int)', $t * $i); + assertType('(float|int)', $t / $i); + assertType('(float|int)', $t ** $i); + assertType('(float|int)', $t << $i); + assertType('int', $t % $i); + + $a = $t; + $a += $i; + assertType('(float|int)', $a); + + $a = $t; + $a++; + assertType('(float|int)', $a); +} diff --git a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php index daccbe3882c..bdd01c80cb2 100644 --- a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php @@ -542,6 +542,13 @@ public function testBug11507(): void $this->analyse([__DIR__ . '/data/bug-11507.php'], []); } + public function testBug15388(): void + { + $this->checkNullables = true; + $this->checkExplicitMixed = false; + $this->analyse([__DIR__ . '/data/bug-15388.php'], []); + } + public function testBug15245(): void { $this->checkNullables = true; diff --git a/tests/PHPStan/Rules/Functions/data/bug-15388.php b/tests/PHPStan/Rules/Functions/data/bug-15388.php new file mode 100644 index 00000000000..ff9841e83bc --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-15388.php @@ -0,0 +1,21 @@ + $vals */ +function foo(array $vals): int { + return array_reduce($vals, function($carry, $val) { + return $carry + map($val); + }, 0); +} + +/** @param list $vals */ +function bar(array $vals): int { + return array_reduce($vals, function($carry, $val) { + return $carry + $val; + }, 0); +} + +function map(int $val): int { + return $val; +} diff --git a/turbo-ext/src/InitializerExprTypeResolver.cpp b/turbo-ext/src/InitializerExprTypeResolver.cpp index a8dccb753ac..cb1e968af0e 100644 --- a/turbo-ext/src/InitializerExprTypeResolver.cpp +++ b/turbo-ext/src/InitializerExprTypeResolver.cpp @@ -2678,7 +2678,10 @@ class InitializerExprTypeResolver IETR_VAL(leftNumberType, callOn(leftType, mToNumber)); IETR_VAL(rightNumberType, callOn(rightType, mToNumber)); - bool typesMixed = isA(types.raw(), pt_ce_mixed_type); + // a template type of mixed does not absorb the other operand in the union + bool typesMixed = isA(types.raw(), pt_ce_mixed_type) + || isA(leftType, pt_ce_mixed_type) + || isA(rightType, pt_ce_mixed_type); if ( !typesMixed && (