Skip to content

Add a dynamic return type extension for clamp() - #6684

Merged
VincentLanglet merged 3 commits into
phpstan:2.3.xfrom
Amoifr:feature-15366-clamp-return-type
Oct 13, 2026
Merged

VincentLanglet merged 3 commits into
phpstan:2.3.xfrom
Amoifr:feature-15366-clamp-return-type

Conversation

@Amoifr

@Amoifr Amoifr commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Closes phpstan/phpstan#15366

PHP 8.6 adds clamp(mixed $value, mixed $min, mixed $max): mixed. php_math_clamp() returns a copy of one of its three arguments: $max when $max < $value, otherwise $min when $value < $min, otherwise $value.

The new extension rewrites the call into that ternary, with AlwaysRememberedExpr, the way MinMaxFunctionReturnTypeExtension handles min($a, $b) and max($a, $b). So clamp($int, 1, 10) is int<1, 10>, clamp($int, 1.0, 10.0) is 1.0|10.0|int<1, 10>, and constant arguments give a constant result. Arrays, which PHP compares by size first, and unpacked arguments keep the native mixed.

The type inference test needs clamp() at runtime, so it is gated with // lint >= 8.6.

Comment on lines +32 to +36
foreach ($args as $arg) {
if ($arg->unpack) {
return null;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't remember having such check in other dynamic extension. Is it really needed ? (or can't it be supported ?)

This doesn't seems to be tested

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess its inspired by MinMaxFunctionReturnTypeExtension. agree it needs more tests

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, it can be supported. I checked every case against what PHP 8.6 actually returns: arrays compare by count and then element by element, and an array is greater than any scalar. PHPStan keeps those comparisons as bool, so the result is the union of the three arguments, which always contains the real value. Removed in bf9e838; int|array<int> now gives array<int>|int<1, 10> instead of mixed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the above comment was about the if ($arg->unpack) { which means we need tests for using this function with splat operator.

I don't think we need precise types for clamp(...$x) now but at least a test which covers the current result would be good

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for clarifying, and sorry @VincentLanglet, I answered about the wrong line. The check is needed: without it, the extension takes an unpacked array for the argument itself, so clamp(...[$i], ...[1], ...[10]) gives array{int} and clamp($i, 1, ...[10]) gives array{10}|int<1, max>. Added doSplat() in 8362760; it pins the current mixed result and fails without the check.

@VincentLanglet
VincentLanglet requested a review from staabm October 12, 2026 22:40
$maxType = $scope->getType($maxExpr);

// arrays are compared by size first, keep the native return type for them
if (!$valueType->isArray()->no() || !$minType->isArray()->no() || !$maxType->isArray()->no()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looking at this condition makes me feel we miss tests for values which are maybe an array, like int|array or something

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition is gone (see the thread above), and int|array<int>, list<int> and constant arrays are now tested in doArrays().

Comment on lines +32 to +36
foreach ($args as $arg) {
if ($arg->unpack) {
return null;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess its inspired by MinMaxFunctionReturnTypeExtension. agree it needs more tests

Comment on lines +50 to +52
$value = new AlwaysRememberedExpr($valueExpr, $valueType, $scope->getNativeType($valueExpr));
$min = new AlwaysRememberedExpr($minExpr, $minType, $scope->getNativeType($minExpr));
$max = new AlwaysRememberedExpr($maxExpr, $maxType, $scope->getNativeType($maxExpr));

@staabm staabm Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need AlwaysRememberedExpr. there is no test failling if we strip this to

		// same comparisons as php_math_clamp(): $max < $value ? $max : ($value < $min ? $min : $value)
		return $scope->getType(new Ternary(
			new Smaller($maxExpr, $valueExpr),
			$maxExpr,
			new Ternary(
				new Smaller($valueExpr, $minExpr),
				$minExpr,
				$valueExpr,
			),
		));

see cc57957 in which it was added to MinMax

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's needed for impure arguments, for the same reason as cc57957: without it, clamp(impureInt(), 1, 10) is int instead of int<1, 10>, because the call is evaluated again in each branch of the ternary. I added that case, and it fails without the wrapper.

*/
function doFoo(int $i, float $f, int|float $n, int $a, int $b, string $s, ?int $maybe, int $percent, int $delta, array $list): void
{
assertType('int<1, 10>', clamp($i, 1, 10));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please add tests and asserts for how the return type is inferered when min/max is

  • a union of const integers
  • a integer range

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in doBounds(): unions of constant integers and integer ranges, for both bounds.


declare(strict_types = 1);

namespace Clamp;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://www.php.net/clamp contains a example with datetime objects. please also test that

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the php.net example in doDates(), plus a mixed DateTime/DateTimeImmutable case.

@staabm
staabm requested a review from VincentLanglet October 13, 2026 11:14
@VincentLanglet
VincentLanglet merged commit 62ed200 into phpstan:2.3.x Oct 13, 2026
971 of 976 checks passed
@staabm

staabm commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Thank you 🙏

@Amoifr
Amoifr deleted the feature-15366-clamp-return-type branch October 18, 2026 10:02
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.

Add dynamic return type extension for clamp function

3 participants