Skip to content

Commit 1fa9600

Browse files
phpstan-botclaude
andcommitted
Improve type narrowing for partially redundant casts in comparisons
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>
1 parent 6f5cbae commit 1fa9600

2 files changed

Lines changed: 99 additions & 7 deletions

File tree

src/Analyser/TypeSpecifier.php

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -493,63 +493,123 @@ public function specifyTypesInCondition(
493493
}
494494

495495
$leftExpr = $expr->left;
496+
$leftPartialCast = null;
496497
if ($leftExpr instanceof Expr\Cast) {
497498
$castedType = $scope->getType($leftExpr);
498499
$innerType = $scope->getType($leftExpr->expr);
499500
if ($castedType->equals($innerType)) {
500501
$leftExpr = $leftExpr->expr;
502+
} elseif ($innerType instanceof UnionType) {
503+
$nonRedundant = $this->filterNonRedundantCastTypes($leftExpr, $innerType);
504+
if ($nonRedundant !== null) {
505+
$leftPartialCast = [$leftExpr->expr, $castedType, $nonRedundant];
506+
}
501507
}
502508
}
503509
$rightExpr = $expr->right;
510+
$rightPartialCast = null;
504511
if ($rightExpr instanceof Expr\Cast) {
505512
$castedType = $scope->getType($rightExpr);
506513
$innerType = $scope->getType($rightExpr->expr);
507514
if ($castedType->equals($innerType)) {
508515
$rightExpr = $rightExpr->expr;
516+
} elseif ($innerType instanceof UnionType) {
517+
$nonRedundant = $this->filterNonRedundantCastTypes($rightExpr, $innerType);
518+
if ($nonRedundant !== null) {
519+
$rightPartialCast = [$rightExpr->expr, $castedType, $nonRedundant];
520+
}
509521
}
510522
}
511523

512524
if ($context->true()) {
513525
if (!$leftExpr instanceof Node\Scalar) {
526+
$narrowedLeftType = $orEqual ? $rightType->getSmallerOrEqualType($this->phpVersion) : $rightType->getSmallerType($this->phpVersion);
514527
$result = $result->unionWith(
515528
$this->create(
516529
$leftExpr,
517-
$orEqual ? $rightType->getSmallerOrEqualType($this->phpVersion) : $rightType->getSmallerType($this->phpVersion),
530+
$narrowedLeftType,
518531
TypeSpecifierContext::createTruthy(),
519532
$scope,
520533
)->setRootExpr($expr),
521534
);
535+
if ($leftPartialCast !== null) {
536+
[$innerExpr, $castType, $nonRedundantType] = $leftPartialCast;
537+
$result = $result->unionWith(
538+
$this->create(
539+
$innerExpr,
540+
TypeCombinator::union(TypeCombinator::intersect($narrowedLeftType, $castType), $nonRedundantType),
541+
TypeSpecifierContext::createTruthy(),
542+
$scope,
543+
)->setRootExpr($expr),
544+
);
545+
}
522546
}
523547
if (!$rightExpr instanceof Node\Scalar) {
548+
$narrowedRightType = $orEqual ? $leftType->getGreaterOrEqualType($this->phpVersion) : $leftType->getGreaterType($this->phpVersion);
524549
$result = $result->unionWith(
525550
$this->create(
526551
$rightExpr,
527-
$orEqual ? $leftType->getGreaterOrEqualType($this->phpVersion) : $leftType->getGreaterType($this->phpVersion),
552+
$narrowedRightType,
528553
TypeSpecifierContext::createTruthy(),
529554
$scope,
530555
)->setRootExpr($expr),
531556
);
557+
if ($rightPartialCast !== null) {
558+
[$innerExpr, $castType, $nonRedundantType] = $rightPartialCast;
559+
$result = $result->unionWith(
560+
$this->create(
561+
$innerExpr,
562+
TypeCombinator::union(TypeCombinator::intersect($narrowedRightType, $castType), $nonRedundantType),
563+
TypeSpecifierContext::createTruthy(),
564+
$scope,
565+
)->setRootExpr($expr),
566+
);
567+
}
532568
}
533569
} elseif ($context->false()) {
534570
if (!$leftExpr instanceof Node\Scalar) {
571+
$narrowedLeftType = $orEqual ? $rightType->getGreaterType($this->phpVersion) : $rightType->getGreaterOrEqualType($this->phpVersion);
535572
$result = $result->unionWith(
536573
$this->create(
537574
$leftExpr,
538-
$orEqual ? $rightType->getGreaterType($this->phpVersion) : $rightType->getGreaterOrEqualType($this->phpVersion),
575+
$narrowedLeftType,
539576
TypeSpecifierContext::createTruthy(),
540577
$scope,
541578
)->setRootExpr($expr),
542579
);
580+
if ($leftPartialCast !== null) {
581+
[$innerExpr, $castType, $nonRedundantType] = $leftPartialCast;
582+
$result = $result->unionWith(
583+
$this->create(
584+
$innerExpr,
585+
TypeCombinator::union(TypeCombinator::intersect($narrowedLeftType, $castType), $nonRedundantType),
586+
TypeSpecifierContext::createTruthy(),
587+
$scope,
588+
)->setRootExpr($expr),
589+
);
590+
}
543591
}
544592
if (!$rightExpr instanceof Node\Scalar) {
593+
$narrowedRightType = $orEqual ? $leftType->getSmallerType($this->phpVersion) : $leftType->getSmallerOrEqualType($this->phpVersion);
545594
$result = $result->unionWith(
546595
$this->create(
547596
$rightExpr,
548-
$orEqual ? $leftType->getSmallerType($this->phpVersion) : $leftType->getSmallerOrEqualType($this->phpVersion),
597+
$narrowedRightType,
549598
TypeSpecifierContext::createTruthy(),
550599
$scope,
551600
)->setRootExpr($expr),
552601
);
602+
if ($rightPartialCast !== null) {
603+
[$innerExpr, $castType, $nonRedundantType] = $rightPartialCast;
604+
$result = $result->unionWith(
605+
$this->create(
606+
$innerExpr,
607+
TypeCombinator::union(TypeCombinator::intersect($narrowedRightType, $castType), $nonRedundantType),
608+
TypeSpecifierContext::createTruthy(),
609+
$scope,
610+
)->setRootExpr($expr),
611+
);
612+
}
553613
}
554614
}
555615

@@ -3249,4 +3309,36 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
32493309
return (new SpecifiedTypes([], []))->setRootExpr($expr);
32503310
}
32513311

3312+
private function filterNonRedundantCastTypes(Expr\Cast $cast, UnionType $innerType): ?Type
3313+
{
3314+
$nonRedundantTypes = [];
3315+
$hasRedundant = false;
3316+
foreach ($innerType->getTypes() as $memberType) {
3317+
$convertedType = null;
3318+
if ($cast instanceof Expr\Cast\Int_) {
3319+
$convertedType = $memberType->toInteger();
3320+
} elseif ($cast instanceof Expr\Cast\Double) {
3321+
$convertedType = $memberType->toFloat();
3322+
} elseif ($cast instanceof Expr\Cast\String_) {
3323+
$convertedType = $memberType->toString();
3324+
} elseif ($cast instanceof Expr\Cast\Bool_) {
3325+
$convertedType = $memberType->toBoolean();
3326+
} elseif ($cast instanceof Expr\Cast\Array_) {
3327+
$convertedType = $memberType->toArray();
3328+
}
3329+
3330+
if ($convertedType !== null && $convertedType->equals($memberType)) {
3331+
$hasRedundant = true;
3332+
} else {
3333+
$nonRedundantTypes[] = $memberType;
3334+
}
3335+
}
3336+
3337+
if ($hasRedundant && $nonRedundantTypes !== []) {
3338+
return TypeCombinator::union(...$nonRedundantTypes);
3339+
}
3340+
3341+
return null;
3342+
}
3343+
32523344
}

tests/PHPStan/Analyser/nsrt/bug-7858.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ function baz($year): void
2626
if (!ctype_digit($year) || (int)$year < 2022) {
2727
throw new \RuntimeException();
2828
}
29-
assertType('int<48, 57>|int<256, max>|numeric-string', $year);
29+
assertType('int<2022, max>|numeric-string', $year);
3030
assertType('int<2022, max>', (int) $year);
3131
}
3232

@@ -35,7 +35,7 @@ function bam(int|string $year): void
3535
if (!ctype_digit($year) || (int)$year < 2022) {
3636
throw new \RuntimeException();
3737
}
38-
assertType('int<48, 57>|int<256, max>|numeric-string', $year);
38+
assertType('int<2022, max>|numeric-string', $year);
3939
assertType('int<2022, max>', (int) $year);
4040
}
4141

@@ -53,6 +53,6 @@ function bak($mixed): void
5353
if (!is_numeric($mixed) || (int)$mixed < 2022) {
5454
throw new \RuntimeException();
5555
}
56-
assertType("float|int|numeric-string", $mixed);
56+
assertType('float|int<2022, max>|numeric-string', $mixed);
5757
assertType('int<2022, max>', (int) $mixed);
5858
}

0 commit comments

Comments
 (0)