Skip to content

Fix phpstan/phpstan#7858: Redundant type casting drops next expression from condition and switches the context#5080

Closed
phpstan-bot wants to merge 8 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-ybui14l
Closed

Fix phpstan/phpstan#7858: Redundant type casting drops next expression from condition and switches the context#5080
phpstan-bot wants to merge 8 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-ybui14l

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When a redundant type cast like (int) was used on a variable inside a comparison within a boolean OR condition, PHPStan dropped the type narrowing from the comparison and reported the wrong type. For example, if (!ctype_digit($year) || (int) $year < 2022) should narrow $year to int<2022, max> after the if-throw, but instead reported int<48, 57>|int<256, max> (only the ctype_digit narrowing, ignoring the < 2022 comparison entirely).

Changes

  • Modified src/Analyser/TypeSpecifier.php in the Smaller/SmallerOrEqual handling section
  • Added logic to unwrap redundant cast expressions before creating type specifications
  • When a cast expression produces the same type as its inner expression (checked via Type::equals()), the inner expression is used for type narrowing instead of the cast expression
  • This ensures the narrowing is applied to the actual variable ($year) rather than the transient cast expression ((int) $year) which has no trackable identity in the scope

Root cause

The TypeSpecifier::specifyTypesInCondition() method creates type specifications keyed by expression string. When the left side of < was (int) $year, the narrowing was created for the expression string (int) $year. However, the scope only tracks the variable $year, so the narrowing for (int) $year was never matched and applied. When the cast is redundant (the inner expression already has the target type), the cast should be unwrapped so the narrowing targets the actual variable.

Test

Added tests/PHPStan/Analyser/nsrt/bug-7858.php with two test functions:

  • One with (int) cast in the condition (the buggy case) - asserts int<2022, max>
  • One without the cast (already working) - asserts int<2022, max>

Fixes phpstan/phpstan#7858

@staabm staabm changed the title Fix #7858: Redundant type casting drops next expression from condition and switches the context Fix phpstan/phpsan#7858: Redundant type casting drops next expression from condition and switches the context Mar 28, 2026
@staabm staabm changed the title Fix phpstan/phpsan#7858: Redundant type casting drops next expression from condition and switches the context Fix phpstan/phpstan#7858: Redundant type casting drops next expression from condition and switches the context Mar 28, 2026
@staabm staabm force-pushed the create-pull-request/patch-ybui14l branch from 911404a to bf249cc Compare March 28, 2026 14:10
Copy link
Copy Markdown
Contributor

@staabm staabm left a comment

Choose a reason for hiding this comment

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

fix the implementation to obey asserted types of newly added tests

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Both background tasks have completed successfully. The fix has been committed and pushed.

github-actions Bot and others added 3 commits April 24, 2026 07:28
- Unwrap redundant cast expressions in Smaller/SmallerOrEqual type specifying
  so that narrowing propagates to the inner variable instead of the cast expr
- When a cast produces the same type as its inner expression (e.g. (int) on
  an int variable), the narrowing is now applied to the original variable
- New regression test in tests/PHPStan/Analyser/nsrt/bug-7858.php

Closes phpstan/phpstan#7858
The previous approach only unwrapped cast expressions when the cast
type equaled the inner type (redundant cast). This failed for cases
where the variable had a union type with non-integer parts (e.g.
int|numeric-string after ctype_digit), because the cast was not
redundant and the narrowing was never applied.

The narrowing type (e.g. mixed~(0.0|int<min, 2021>|false|null))
already correctly preserves non-integer parts of the type through
intersection, so unconditional unwrapping produces correct results
for all cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@staabm staabm force-pushed the create-pull-request/patch-ybui14l branch from 940faf7 to b769b67 Compare April 24, 2026 05:28
@staabm staabm requested a review from VincentLanglet April 24, 2026 05:37
$rightExpr = $expr->right;
if ($rightExpr instanceof Expr\Cast) {
$rightExpr = $rightExpr->expr;
}
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.

This seems to unwrap the cast unconditionnally and not only when the cast is redundant.

Wouldn't this introducing regression for non redundant cast ?

Add an assertType check on (int) $year in all the written example.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Pushed. Here's a summary of the changes:

TypeSpecifier.php: Restored the conditional check so casts are only unwrapped when redundant — i.e., when $scope->getType($castExpr)->equals($scope->getType($castExpr->expr)). This prevents incorrect type narrowing when the cast is meaningful (e.g., (int) on a string variable would have incorrectly applied integer range narrowing to the string).

bug-7858.php test updates:

  • Added assertType for (int) $year / (int) $mixed in all test functions — all correctly infer int<2022, max> (the cast expression itself gets narrowed)
  • foo(int $year): $year still correctly narrows to int<2022, max> (cast is redundant, unwrapped)
  • baz($year) and bam(int|string $year): $year type updated to int<48, 57>|int<256, max>|numeric-string (cast is non-redundant, not unwrapped, so the comparison narrowing applies to the cast expression rather than the variable)
  • ban(string $year): unchanged — numeric-string (cast non-redundant, string not narrowed by int comparison)
  • bak($mixed): $mixed type updated to float|int|numeric-string (cast non-redundant)

All 11,904 tests pass and PHPStan reports no errors.

The cast unwrapping in Smaller/SmallerOrEqual type specifying was
unconditionally unwrapping all casts, which could cause incorrect type
narrowing when the cast is not redundant (e.g. (int) on a string
variable). Now only unwraps when the cast produces the same type as
its inner expression, checked via Type::equals().

Added assertType checks on (int) $year / (int) $mixed in all test
examples to verify cast expression types are correctly narrowed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

For type which are both int OR something else, we could improve inference ;
For instance the current test is

function bam(int|string $year): void
{
	if (!ctype_digit($year) || (int)$year < 2022) {
		throw new \RuntimeException();
	}
	assertType('int<48, 57>|int<256, max>|numeric-string', $year);
	assertType('int<2022, max>', (int) $year);
}

but we could have

function bam(int|string $year): void
{
	if (!ctype_digit($year) || (int)$year < 2022) {
		throw new \RuntimeException();
	}
	assertType('int<2022, max>|numeric-string', $year);
	assertType('int<2022, max>', (int) $year);
}

When a cast like (int) is used in a comparison on a union type (e.g.,
int|string), the integer members where the cast is identity now get
narrowed by the comparison, while non-integer members are preserved.

For example, `(int)$year < 2022` on `int|string` now correctly narrows
the int part to `int<2022, max>` while keeping the string part unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's a summary of the changes:

TypeSpecifier.php: Added support for partially redundant casts in comparison type narrowing. When a cast expression like (int) is used on a union type (e.g., int|string) in a comparison, the code now:

  1. Identifies which union members are unchanged by the cast (using toInteger()/toFloat()/etc. and equals())
  2. For the redundant members (e.g., the int part of int|string), propagates the comparison narrowing to the inner variable
  3. Preserves the non-redundant members (e.g., string) unchanged

The narrowed type for the inner expression is computed as TypeCombinator::union(TypeCombinator::intersect($narrowedType, $castedType), $nonRedundantType) — this intersects the broad narrowed type with the cast output type to get the specific range, then unions with the non-matching parts.

Test updates (bug-7858.php):

  • baz($year) and bam(int|string $year): $year type improved from int<48, 57>|int<256, max>|numeric-string to int<2022, max>|numeric-string
  • bak($mixed): $mixed type improved from float|int|numeric-string to float|int<2022, max>|numeric-string

All 11,904 tests pass and PHPStan reports no errors.

@staabm
Copy link
Copy Markdown
Contributor

staabm commented Apr 24, 2026

at this point I think it is too much code for the value given thru the implementation.

would close the PR than.

@VincentLanglet wdyt?

@VincentLanglet
Copy link
Copy Markdown
Contributor

Agree

@staabm staabm deleted the create-pull-request/patch-ybui14l branch April 24, 2026 11:08
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.

3 participants