From 9175625ad2d9058835596def655a842794dacb22 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 7 May 2026 16:32:39 +0200 Subject: [PATCH 1/5] Extract `Type::toBitwiseNotType()` for `~$x` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces `InitializerExprTypeResolver::getBitwiseNotTypeFromType()`'s hand-rolled `TypeTraverser::map` with a polymorphic `Type` method. Each leaf type knows what `~` produces: - `ConstantStringType` / `ConstantIntegerType` / `ConstantFloatType`: the actual computed value as a constant. - `StringType`: `string`. - `IntegerType` / `FloatType` (and `IntegerRangeType` via inheritance): `int`. - Accessory string types: those whose `isNonEmptyString()` is `Yes` (`NonEmpty`, `NonFalsy`, `Numeric`) survive as `string&non-empty-string`; `Literal`/`Lowercase`/`Uppercase` collapse to plain `string` (`~$s` doesn't preserve those refinements). - `UnionType` / `IntersectionType`: distribute via `unionTypes` / `intersectTypes`. - `NeverType`: pass through (was `ErrorType` before — incidental improvement of polymorphic dispatch). - Everything else (objects, arrays, bools, null, void, resource, mixed, etc.): `ErrorType`. Pure refactor: full test suite + phpstan + cs pass. --- .../InitializerExprTypeResolver.php | 28 +------------------ src/Type/Accessory/AccessoryArrayListType.php | 5 ++++ .../Accessory/AccessoryLiteralStringType.php | 5 ++++ .../AccessoryLowercaseStringType.php | 5 ++++ .../Accessory/AccessoryNonEmptyStringType.php | 5 ++++ .../Accessory/AccessoryNonFalsyStringType.php | 5 ++++ .../Accessory/AccessoryNumericStringType.php | 5 ++++ .../AccessoryUppercaseStringType.php | 5 ++++ src/Type/Accessory/HasOffsetType.php | 5 ++++ src/Type/Accessory/HasOffsetValueType.php | 5 ++++ src/Type/Accessory/NonEmptyArrayType.php | 5 ++++ src/Type/Accessory/OversizedArrayType.php | 5 ++++ src/Type/BooleanType.php | 5 ++++ src/Type/CallableType.php | 5 ++++ src/Type/ClosureType.php | 5 ++++ src/Type/Constant/ConstantBooleanType.php | 6 ++++ src/Type/Constant/ConstantFloatType.php | 5 ++++ src/Type/Constant/ConstantIntegerType.php | 5 ++++ src/Type/Constant/ConstantStringType.php | 5 ++++ src/Type/FloatType.php | 5 ++++ src/Type/IntegerType.php | 5 ++++ src/Type/IntersectionType.php | 5 ++++ src/Type/IterableType.php | 5 ++++ src/Type/MixedType.php | 5 ++++ src/Type/NeverType.php | 5 ++++ src/Type/NonexistentParentClassType.php | 5 ++++ src/Type/NullType.php | 5 ++++ src/Type/ObjectType.php | 5 ++++ src/Type/ResourceType.php | 5 ++++ src/Type/StaticType.php | 5 ++++ src/Type/StrictMixedType.php | 5 ++++ src/Type/StringType.php | 5 ++++ src/Type/Traits/ArrayTypeTrait.php | 5 ++++ src/Type/Traits/LateResolvableTypeTrait.php | 5 ++++ src/Type/Traits/ObjectTypeTrait.php | 5 ++++ src/Type/Type.php | 3 ++ src/Type/UnionType.php | 5 ++++ src/Type/VoidType.php | 5 ++++ 38 files changed, 185 insertions(+), 27 deletions(-) diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 88e19731792..aff125305a6 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -2699,33 +2699,7 @@ public function getBitwiseNotType(Expr $expr, callable $getTypeCallback): Type public function getBitwiseNotTypeFromType(Type $exprType): Type { - return TypeTraverser::map($exprType, static function (Type $type, callable $traverse): Type { - if ($type instanceof UnionType || $type instanceof IntersectionType) { - return $traverse($type); - } - if ($type instanceof ConstantStringType) { - return new ConstantStringType(~$type->getValue()); - } - if ($type->isString()->yes()) { - $accessories = []; - if (!$type->isNonEmptyString()->yes()) { - return new StringType(); - } - - $accessories[] = new AccessoryNonEmptyStringType(); - // it is not useful to apply numeric and literal strings here. - // numeric string isn't certainly kept numeric: 3v4l.org/JERDB - - return new IntersectionType([new StringType(), ...$accessories]); - } - if ($type instanceof ConstantIntegerType || $type instanceof ConstantFloatType) { - return new ConstantIntegerType(~ (int) $type->getValue()); - } - if ($type->isInteger()->yes() || $type->isFloat()->yes()) { - return new IntegerType(); - } - return new ErrorType(); - }); + return $exprType->toBitwiseNotType(); } private function resolveName(Name $name, ?ClassReflection $classReflection): string diff --git a/src/Type/Accessory/AccessoryArrayListType.php b/src/Type/Accessory/AccessoryArrayListType.php index e8af2f9a9a0..a5b4a02db2d 100644 --- a/src/Type/Accessory/AccessoryArrayListType.php +++ b/src/Type/Accessory/AccessoryArrayListType.php @@ -483,6 +483,11 @@ public function toNumber(): Type return new ErrorType(); } + public function toBitwiseNotType(): Type + { + return new ErrorType(); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/Accessory/AccessoryLiteralStringType.php b/src/Type/Accessory/AccessoryLiteralStringType.php index da1abf5e370..de752651f4b 100644 --- a/src/Type/Accessory/AccessoryLiteralStringType.php +++ b/src/Type/Accessory/AccessoryLiteralStringType.php @@ -181,6 +181,11 @@ public function toNumber(): Type return new ErrorType(); } + public function toBitwiseNotType(): Type + { + return new StringType(); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/Accessory/AccessoryLowercaseStringType.php b/src/Type/Accessory/AccessoryLowercaseStringType.php index 2e5ca831461..cb7983281ba 100644 --- a/src/Type/Accessory/AccessoryLowercaseStringType.php +++ b/src/Type/Accessory/AccessoryLowercaseStringType.php @@ -178,6 +178,11 @@ public function toNumber(): Type return new ErrorType(); } + public function toBitwiseNotType(): Type + { + return new StringType(); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/Accessory/AccessoryNonEmptyStringType.php b/src/Type/Accessory/AccessoryNonEmptyStringType.php index 2499084ba4c..af8a834852d 100644 --- a/src/Type/Accessory/AccessoryNonEmptyStringType.php +++ b/src/Type/Accessory/AccessoryNonEmptyStringType.php @@ -183,6 +183,11 @@ public function toNumber(): Type return new ErrorType(); } + public function toBitwiseNotType(): Type + { + return new IntersectionType([new StringType(), new self()]); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/Accessory/AccessoryNonFalsyStringType.php b/src/Type/Accessory/AccessoryNonFalsyStringType.php index 9f2eebdbd2d..43f5aaab9d4 100644 --- a/src/Type/Accessory/AccessoryNonFalsyStringType.php +++ b/src/Type/Accessory/AccessoryNonFalsyStringType.php @@ -185,6 +185,11 @@ public function toNumber(): Type return new ErrorType(); } + public function toBitwiseNotType(): Type + { + return new IntersectionType([new StringType(), new AccessoryNonEmptyStringType()]); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/Accessory/AccessoryNumericStringType.php b/src/Type/Accessory/AccessoryNumericStringType.php index 88a811bc1be..fc52dfdefa7 100644 --- a/src/Type/Accessory/AccessoryNumericStringType.php +++ b/src/Type/Accessory/AccessoryNumericStringType.php @@ -177,6 +177,11 @@ public function toNumber(): Type ]); } + public function toBitwiseNotType(): Type + { + return new IntersectionType([new StringType(), new AccessoryNonEmptyStringType()]); + } + public function toAbsoluteNumber(): Type { return $this->toNumber()->toAbsoluteNumber(); diff --git a/src/Type/Accessory/AccessoryUppercaseStringType.php b/src/Type/Accessory/AccessoryUppercaseStringType.php index a85c74745be..a67ec87060e 100644 --- a/src/Type/Accessory/AccessoryUppercaseStringType.php +++ b/src/Type/Accessory/AccessoryUppercaseStringType.php @@ -178,6 +178,11 @@ public function toNumber(): Type return new ErrorType(); } + public function toBitwiseNotType(): Type + { + return new StringType(); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/Accessory/HasOffsetType.php b/src/Type/Accessory/HasOffsetType.php index 8146403416d..313de6aeb01 100644 --- a/src/Type/Accessory/HasOffsetType.php +++ b/src/Type/Accessory/HasOffsetType.php @@ -427,6 +427,11 @@ public function toNumber(): Type return new ErrorType(); } + public function toBitwiseNotType(): Type + { + return new ErrorType(); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/Accessory/HasOffsetValueType.php b/src/Type/Accessory/HasOffsetValueType.php index f72d0641107..d20ab5c9167 100644 --- a/src/Type/Accessory/HasOffsetValueType.php +++ b/src/Type/Accessory/HasOffsetValueType.php @@ -506,6 +506,11 @@ public function toNumber(): Type return new ErrorType(); } + public function toBitwiseNotType(): Type + { + return new ErrorType(); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/Accessory/NonEmptyArrayType.php b/src/Type/Accessory/NonEmptyArrayType.php index 6fdeddd96e8..2e8afa85e1d 100644 --- a/src/Type/Accessory/NonEmptyArrayType.php +++ b/src/Type/Accessory/NonEmptyArrayType.php @@ -472,6 +472,11 @@ public function toNumber(): Type return new ErrorType(); } + public function toBitwiseNotType(): Type + { + return new ErrorType(); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/Accessory/OversizedArrayType.php b/src/Type/Accessory/OversizedArrayType.php index ca6da6e4caa..03c26560b6c 100644 --- a/src/Type/Accessory/OversizedArrayType.php +++ b/src/Type/Accessory/OversizedArrayType.php @@ -439,6 +439,11 @@ public function toNumber(): Type return new ErrorType(); } + public function toBitwiseNotType(): Type + { + return new ErrorType(); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/BooleanType.php b/src/Type/BooleanType.php index 1782dafa777..365c44ac1eb 100644 --- a/src/Type/BooleanType.php +++ b/src/Type/BooleanType.php @@ -66,6 +66,11 @@ public function toNumber(): Type return $this->toInteger(); } + public function toBitwiseNotType(): Type + { + return new ErrorType(); + } + public function toAbsoluteNumber(): Type { return $this->toNumber()->toAbsoluteNumber(); diff --git a/src/Type/CallableType.php b/src/Type/CallableType.php index adad99a6331..c2760ef583e 100644 --- a/src/Type/CallableType.php +++ b/src/Type/CallableType.php @@ -409,6 +409,11 @@ public function toNumber(): Type return new ErrorType(); } + public function toBitwiseNotType(): Type + { + return new ErrorType(); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/ClosureType.php b/src/Type/ClosureType.php index 23ee9dc162e..9bf52599ea3 100644 --- a/src/Type/ClosureType.php +++ b/src/Type/ClosureType.php @@ -510,6 +510,11 @@ public function toNumber(): Type return new ErrorType(); } + public function toBitwiseNotType(): Type + { + return new ErrorType(); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/Constant/ConstantBooleanType.php b/src/Type/Constant/ConstantBooleanType.php index 282b005c158..6800f7a8105 100644 --- a/src/Type/Constant/ConstantBooleanType.php +++ b/src/Type/Constant/ConstantBooleanType.php @@ -8,6 +8,7 @@ use PHPStan\TrinaryLogic; use PHPStan\Type\BooleanType; use PHPStan\Type\ConstantScalarType; +use PHPStan\Type\ErrorType; use PHPStan\Type\GeneralizePrecision; use PHPStan\Type\MixedType; use PHPStan\Type\NeverType; @@ -83,6 +84,11 @@ public function toNumber(): Type return new ConstantIntegerType((int) $this->value); } + public function toBitwiseNotType(): Type + { + return new ErrorType(); + } + public function toAbsoluteNumber(): Type { return $this->toNumber()->toAbsoluteNumber(); diff --git a/src/Type/Constant/ConstantFloatType.php b/src/Type/Constant/ConstantFloatType.php index aff555fcfac..27e7d1f6246 100644 --- a/src/Type/Constant/ConstantFloatType.php +++ b/src/Type/Constant/ConstantFloatType.php @@ -89,6 +89,11 @@ public function toInteger(): Type return new ConstantIntegerType((int) $this->value); } + public function toBitwiseNotType(): Type + { + return new ConstantIntegerType(~ (int) $this->value); + } + public function toAbsoluteNumber(): Type { return new self(abs($this->value)); diff --git a/src/Type/Constant/ConstantIntegerType.php b/src/Type/Constant/ConstantIntegerType.php index 6b482c62e6e..3dfb9bff2eb 100644 --- a/src/Type/Constant/ConstantIntegerType.php +++ b/src/Type/Constant/ConstantIntegerType.php @@ -78,6 +78,11 @@ public function toFloat(): Type return new ConstantFloatType($this->value); } + public function toBitwiseNotType(): Type + { + return new self(~$this->value); + } + public function toAbsoluteNumber(): Type { return new self(abs($this->value)); diff --git a/src/Type/Constant/ConstantStringType.php b/src/Type/Constant/ConstantStringType.php index 40d0773c48d..ecfbc91ed7a 100644 --- a/src/Type/Constant/ConstantStringType.php +++ b/src/Type/Constant/ConstantStringType.php @@ -288,6 +288,11 @@ public function toNumber(): Type return new ErrorType(); } + public function toBitwiseNotType(): Type + { + return new ConstantStringType(~$this->value); + } + public function toAbsoluteNumber(): Type { return $this->toNumber()->toAbsoluteNumber(); diff --git a/src/Type/FloatType.php b/src/Type/FloatType.php index 5df57fe7955..b5439f8048c 100644 --- a/src/Type/FloatType.php +++ b/src/Type/FloatType.php @@ -103,6 +103,11 @@ public function toNumber(): Type return $this; } + public function toBitwiseNotType(): Type + { + return new IntegerType(); + } + public function toAbsoluteNumber(): Type { return $this; diff --git a/src/Type/IntegerType.php b/src/Type/IntegerType.php index 62dafd4ade1..a378a7e7bcd 100644 --- a/src/Type/IntegerType.php +++ b/src/Type/IntegerType.php @@ -57,6 +57,11 @@ public function toNumber(): Type return $this; } + public function toBitwiseNotType(): Type + { + return new IntegerType(); + } + public function toAbsoluteNumber(): Type { return IntegerRangeType::createAllGreaterThanOrEqualTo(0); diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 714b8360284..0ab57023841 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -1411,6 +1411,11 @@ public function toNumber(): Type return $type; } + public function toBitwiseNotType(): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->toBitwiseNotType()); + } + public function toAbsoluteNumber(): Type { $type = $this->intersectTypes(static fn (Type $type): Type => $type->toAbsoluteNumber()); diff --git a/src/Type/IterableType.php b/src/Type/IterableType.php index 2cf46b754e9..80846cf0983 100644 --- a/src/Type/IterableType.php +++ b/src/Type/IterableType.php @@ -200,6 +200,11 @@ public function toNumber(): Type return new ErrorType(); } + public function toBitwiseNotType(): Type + { + return new ErrorType(); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/MixedType.php b/src/Type/MixedType.php index 807a5ab259b..cb082ea2fb6 100644 --- a/src/Type/MixedType.php +++ b/src/Type/MixedType.php @@ -614,6 +614,11 @@ public function toNumber(): Type ); } + public function toBitwiseNotType(): Type + { + return new ErrorType(); + } + public function toAbsoluteNumber(): Type { return $this->toNumber()->toAbsoluteNumber(); diff --git a/src/Type/NeverType.php b/src/Type/NeverType.php index 9b1bcd9e363..761fc3f9170 100644 --- a/src/Type/NeverType.php +++ b/src/Type/NeverType.php @@ -434,6 +434,11 @@ public function toNumber(): Type return $this; } + public function toBitwiseNotType(): Type + { + return $this; + } + public function toAbsoluteNumber(): Type { return $this; diff --git a/src/Type/NonexistentParentClassType.php b/src/Type/NonexistentParentClassType.php index 5c217604f60..c3e03bc870d 100644 --- a/src/Type/NonexistentParentClassType.php +++ b/src/Type/NonexistentParentClassType.php @@ -163,6 +163,11 @@ public function toNumber(): Type return new ErrorType(); } + public function toBitwiseNotType(): Type + { + return new ErrorType(); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/NullType.php b/src/Type/NullType.php index 5c7730ee9f7..fe940866a15 100644 --- a/src/Type/NullType.php +++ b/src/Type/NullType.php @@ -144,6 +144,11 @@ public function toNumber(): Type return new ConstantIntegerType(0); } + public function toBitwiseNotType(): Type + { + return new ErrorType(); + } + public function toAbsoluteNumber(): Type { return $this->toNumber()->toAbsoluteNumber(); diff --git a/src/Type/ObjectType.php b/src/Type/ObjectType.php index ddd98803841..6d86b52d883 100644 --- a/src/Type/ObjectType.php +++ b/src/Type/ObjectType.php @@ -763,6 +763,11 @@ public function toNumber(): Type return new ErrorType(); } + public function toBitwiseNotType(): Type + { + return new ErrorType(); + } + public function toAbsoluteNumber(): Type { return $this->toNumber()->toAbsoluteNumber(); diff --git a/src/Type/ResourceType.php b/src/Type/ResourceType.php index 9f28095dd80..e284f16d1fc 100644 --- a/src/Type/ResourceType.php +++ b/src/Type/ResourceType.php @@ -55,6 +55,11 @@ public function toNumber(): Type return new ErrorType(); } + public function toBitwiseNotType(): Type + { + return new ErrorType(); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/StaticType.php b/src/Type/StaticType.php index 6e9cfc841bb..3a33d116874 100644 --- a/src/Type/StaticType.php +++ b/src/Type/StaticType.php @@ -753,6 +753,11 @@ public function toNumber(): Type return new ErrorType(); } + public function toBitwiseNotType(): Type + { + return new ErrorType(); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/StrictMixedType.php b/src/Type/StrictMixedType.php index af20367941f..bf16f99bf62 100644 --- a/src/Type/StrictMixedType.php +++ b/src/Type/StrictMixedType.php @@ -401,6 +401,11 @@ public function toNumber(): Type return new ErrorType(); } + public function toBitwiseNotType(): Type + { + return new ErrorType(); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/StringType.php b/src/Type/StringType.php index 730869022fc..8c5fc17f4a8 100644 --- a/src/Type/StringType.php +++ b/src/Type/StringType.php @@ -145,6 +145,11 @@ public function toNumber(): Type return new ErrorType(); } + public function toBitwiseNotType(): Type + { + return new StringType(); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/Traits/ArrayTypeTrait.php b/src/Type/Traits/ArrayTypeTrait.php index a019125c3f0..9ac5e1d97db 100644 --- a/src/Type/Traits/ArrayTypeTrait.php +++ b/src/Type/Traits/ArrayTypeTrait.php @@ -80,6 +80,11 @@ public function toNumber(): Type return new ErrorType(); } + public function toBitwiseNotType(): Type + { + return new ErrorType(); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/Traits/LateResolvableTypeTrait.php b/src/Type/Traits/LateResolvableTypeTrait.php index ff9e91a6f61..4cd01f072e2 100644 --- a/src/Type/Traits/LateResolvableTypeTrait.php +++ b/src/Type/Traits/LateResolvableTypeTrait.php @@ -419,6 +419,11 @@ public function toNumber(): Type return $this->resolve()->toNumber(); } + public function toBitwiseNotType(): Type + { + return $this->resolve()->toBitwiseNotType(); + } + public function toAbsoluteNumber(): Type { return $this->resolve()->toAbsoluteNumber(); diff --git a/src/Type/Traits/ObjectTypeTrait.php b/src/Type/Traits/ObjectTypeTrait.php index 51a4922f43f..6392910a308 100644 --- a/src/Type/Traits/ObjectTypeTrait.php +++ b/src/Type/Traits/ObjectTypeTrait.php @@ -285,6 +285,11 @@ public function toNumber(): Type return new ErrorType(); } + public function toBitwiseNotType(): Type + { + return new ErrorType(); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/Type.php b/src/Type/Type.php index 73c46a213ad..b49fedaa566 100644 --- a/src/Type/Type.php +++ b/src/Type/Type.php @@ -373,6 +373,9 @@ public function toBoolean(): BooleanType; /** Models numeric coercion for arithmetic operators. */ public function toNumber(): Type; + /** Models the bitwise-not (`~$x`) operator. Returns `ErrorType` for types where `~` is undefined. */ + public function toBitwiseNotType(): Type; + /** Models the (int) cast. */ public function toInteger(): Type; diff --git a/src/Type/UnionType.php b/src/Type/UnionType.php index 6a9c3c14657..573c6b7863f 100644 --- a/src/Type/UnionType.php +++ b/src/Type/UnionType.php @@ -1087,6 +1087,11 @@ public function toNumber(): Type return $type; } + public function toBitwiseNotType(): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->toBitwiseNotType()); + } + public function toAbsoluteNumber(): Type { $type = $this->unionTypes(static fn (Type $type): Type => $type->toAbsoluteNumber()); diff --git a/src/Type/VoidType.php b/src/Type/VoidType.php index ca864245e67..e8532c299cc 100644 --- a/src/Type/VoidType.php +++ b/src/Type/VoidType.php @@ -89,6 +89,11 @@ public function toNumber(): Type return new ErrorType(); } + public function toBitwiseNotType(): Type + { + return new ErrorType(); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); From c2317bb82ab06007da0d888715477ccf453329ba Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 7 May 2026 16:51:02 +0200 Subject: [PATCH 2/5] Extract `Type::toGetClassResultType()` for `get_class($x)` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces `GetClassDynamicReturnTypeExtension`'s hand-rolled `TypeTraverser::map` with a polymorphic `Type` method. Each leaf projects to: - definite object: `class-string` (via `$this->getClassStringType()`) - definite non-object: `false` - possibly-object: `class-string|false` The three default-impl traits cover most concrete types — one body in each: - `NonObjectTypeTrait` returns `ConstantBooleanType(false)`. - `MaybeObjectTypeTrait` returns the union with `false`. - `ObjectTypeTrait` delegates to `$this->getClassStringType()`. Special cases: - `StaticType` returns `$this->getClassStringType()` directly so the static binding is preserved as `class-string` / `class-string<$this(X)>` rather than collapsing to the underlying object type. - `ObjectType`, `ClosureType`, `NonexistentParentClassType` use `getClassStringType()` directly. - `MixedType` branches on `isObject()` (its result depends on `$subtractedType`, so the trait isn't enough). - `StrictMixedType` returns `false` (its `isObject()` is `No`). - `NeverType` propagates. - `UnionType`/`IntersectionType` distribute via `unionTypes` / `intersectTypes`. - `LateResolvableTypeTrait` delegates to `resolve()`. Pure refactor: full test suite + phpstan + cs pass. --- src/Type/ClosureType.php | 5 ++++ src/Type/IntersectionType.php | 5 ++++ src/Type/MixedType.php | 15 +++++++++++ src/Type/NeverType.php | 5 ++++ src/Type/NonexistentParentClassType.php | 5 ++++ src/Type/ObjectType.php | 5 ++++ .../GetClassDynamicReturnTypeExtension.php | 27 +------------------ src/Type/StaticType.php | 9 +++++++ src/Type/StrictMixedType.php | 6 +++++ src/Type/Traits/LateResolvableTypeTrait.php | 5 ++++ src/Type/Traits/MaybeObjectTypeTrait.php | 7 +++++ src/Type/Traits/NonObjectTypeTrait.php | 6 +++++ src/Type/Traits/ObjectTypeTrait.php | 5 ++++ src/Type/Type.php | 7 +++++ src/Type/UnionType.php | 5 ++++ 15 files changed, 91 insertions(+), 26 deletions(-) diff --git a/src/Type/ClosureType.php b/src/Type/ClosureType.php index 9bf52599ea3..48efd92a6c5 100644 --- a/src/Type/ClosureType.php +++ b/src/Type/ClosureType.php @@ -515,6 +515,11 @@ public function toBitwiseNotType(): Type return new ErrorType(); } + public function toGetClassResultType(): Type + { + return $this->getClassStringType(); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 0ab57023841..4e7eeefaa9a 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -1416,6 +1416,11 @@ public function toBitwiseNotType(): Type return $this->intersectTypes(static fn (Type $type): Type => $type->toBitwiseNotType()); } + public function toGetClassResultType(): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->toGetClassResultType()); + } + public function toAbsoluteNumber(): Type { $type = $this->intersectTypes(static fn (Type $type): Type => $type->toAbsoluteNumber()); diff --git a/src/Type/MixedType.php b/src/Type/MixedType.php index cb082ea2fb6..49025134e65 100644 --- a/src/Type/MixedType.php +++ b/src/Type/MixedType.php @@ -619,6 +619,21 @@ public function toBitwiseNotType(): Type return new ErrorType(); } + public function toGetClassResultType(): Type + { + $isObject = $this->isObject(); + if ($isObject->no()) { + return new ConstantBooleanType(false); + } + + $classString = $this->getClassStringType(); + if ($isObject->yes()) { + return $classString; + } + + return new UnionType([$classString, new ConstantBooleanType(false)]); + } + public function toAbsoluteNumber(): Type { return $this->toNumber()->toAbsoluteNumber(); diff --git a/src/Type/NeverType.php b/src/Type/NeverType.php index 761fc3f9170..5187168d9b9 100644 --- a/src/Type/NeverType.php +++ b/src/Type/NeverType.php @@ -439,6 +439,11 @@ public function toBitwiseNotType(): Type return $this; } + public function toGetClassResultType(): Type + { + return $this; + } + public function toAbsoluteNumber(): Type { return $this; diff --git a/src/Type/NonexistentParentClassType.php b/src/Type/NonexistentParentClassType.php index c3e03bc870d..ee622d03fb7 100644 --- a/src/Type/NonexistentParentClassType.php +++ b/src/Type/NonexistentParentClassType.php @@ -168,6 +168,11 @@ public function toBitwiseNotType(): Type return new ErrorType(); } + public function toGetClassResultType(): Type + { + return $this->getClassStringType(); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/ObjectType.php b/src/Type/ObjectType.php index 6d86b52d883..2751fdaa7f4 100644 --- a/src/Type/ObjectType.php +++ b/src/Type/ObjectType.php @@ -768,6 +768,11 @@ public function toBitwiseNotType(): Type return new ErrorType(); } + public function toGetClassResultType(): Type + { + return $this->getClassStringType(); + } + public function toAbsoluteNumber(): Type { return $this->toNumber()->toAbsoluteNumber(); diff --git a/src/Type/Php/GetClassDynamicReturnTypeExtension.php b/src/Type/Php/GetClassDynamicReturnTypeExtension.php index c79c2126f71..649cda40956 100644 --- a/src/Type/Php/GetClassDynamicReturnTypeExtension.php +++ b/src/Type/Php/GetClassDynamicReturnTypeExtension.php @@ -10,11 +10,8 @@ use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\DynamicFunctionReturnTypeExtension; -use PHPStan\Type\IntersectionType; use PHPStan\Type\Type; -use PHPStan\Type\TypeTraverser; use PHPStan\Type\TypeUtils; -use PHPStan\Type\UnionType; use function count; #[AutowiredService] @@ -48,29 +45,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, return new ClassStringType(); } - return TypeTraverser::map( - $argType, - static function (Type $type, callable $traverse): Type { - if ($type instanceof UnionType || $type instanceof IntersectionType) { - return $traverse($type); - } - - $isObject = $type->isObject(); - if ($isObject->no()) { - return new ConstantBooleanType(false); - } - - $classStringType = $type->getClassStringType(); - if ($isObject->yes()) { - return $classStringType; - } - - return new UnionType([ - $classStringType, - new ConstantBooleanType(false), - ]); - }, - ); + return $argType->toGetClassResultType(); } } diff --git a/src/Type/StaticType.php b/src/Type/StaticType.php index 3a33d116874..d47eab8de3a 100644 --- a/src/Type/StaticType.php +++ b/src/Type/StaticType.php @@ -758,6 +758,15 @@ public function toBitwiseNotType(): Type return new ErrorType(); } + public function toGetClassResultType(): Type + { + // Preserve static binding (`class-string` / + // `class-string<$this>`) by going through `getClassStringType()` + // directly instead of delegating to the underlying object type, + // which would resolve `static` away. + return $this->getClassStringType(); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/StrictMixedType.php b/src/Type/StrictMixedType.php index bf16f99bf62..49aa5740c79 100644 --- a/src/Type/StrictMixedType.php +++ b/src/Type/StrictMixedType.php @@ -13,6 +13,7 @@ use PHPStan\Reflection\Type\UnresolvedPropertyPrototypeReflection; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; +use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Enum\EnumCaseObjectType; use PHPStan\Type\Generic\TemplateMixedType; use PHPStan\Type\Generic\TemplateTypeMap; @@ -406,6 +407,11 @@ public function toBitwiseNotType(): Type return new ErrorType(); } + public function toGetClassResultType(): Type + { + return new ConstantBooleanType(false); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/Traits/LateResolvableTypeTrait.php b/src/Type/Traits/LateResolvableTypeTrait.php index 4cd01f072e2..8221f160c03 100644 --- a/src/Type/Traits/LateResolvableTypeTrait.php +++ b/src/Type/Traits/LateResolvableTypeTrait.php @@ -424,6 +424,11 @@ public function toBitwiseNotType(): Type return $this->resolve()->toBitwiseNotType(); } + public function toGetClassResultType(): Type + { + return $this->resolve()->toGetClassResultType(); + } + public function toAbsoluteNumber(): Type { return $this->resolve()->toAbsoluteNumber(); diff --git a/src/Type/Traits/MaybeObjectTypeTrait.php b/src/Type/Traits/MaybeObjectTypeTrait.php index d6ff3913746..83f74b13c79 100644 --- a/src/Type/Traits/MaybeObjectTypeTrait.php +++ b/src/Type/Traits/MaybeObjectTypeTrait.php @@ -15,8 +15,10 @@ use PHPStan\Reflection\Type\UnresolvedPropertyPrototypeReflection; use PHPStan\TrinaryLogic; use PHPStan\Type\ClassStringType; +use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\MixedType; use PHPStan\Type\Type; +use PHPStan\Type\UnionType; trait MaybeObjectTypeTrait { @@ -36,6 +38,11 @@ public function getClassStringType(): Type return new ClassStringType(); } + public function toGetClassResultType(): Type + { + return new UnionType([$this->getClassStringType(), new ConstantBooleanType(false)]); + } + public function isEnum(): TrinaryLogic { return TrinaryLogic::createMaybe(); diff --git a/src/Type/Traits/NonObjectTypeTrait.php b/src/Type/Traits/NonObjectTypeTrait.php index 21eb0e8aed3..c47a798cc02 100644 --- a/src/Type/Traits/NonObjectTypeTrait.php +++ b/src/Type/Traits/NonObjectTypeTrait.php @@ -10,6 +10,7 @@ use PHPStan\Reflection\Type\UnresolvedPropertyPrototypeReflection; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; +use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Enum\EnumCaseObjectType; use PHPStan\Type\ErrorType; use PHPStan\Type\Type; @@ -27,6 +28,11 @@ public function getClassStringType(): Type return new ErrorType(); } + public function toGetClassResultType(): Type + { + return new ConstantBooleanType(false); + } + public function isEnum(): TrinaryLogic { return TrinaryLogic::createNo(); diff --git a/src/Type/Traits/ObjectTypeTrait.php b/src/Type/Traits/ObjectTypeTrait.php index 6392910a308..5e9344df11f 100644 --- a/src/Type/Traits/ObjectTypeTrait.php +++ b/src/Type/Traits/ObjectTypeTrait.php @@ -41,6 +41,11 @@ public function isObject(): TrinaryLogic return TrinaryLogic::createYes(); } + public function toGetClassResultType(): Type + { + return $this->getClassStringType(); + } + public function isEnum(): TrinaryLogic { return TrinaryLogic::createMaybe(); diff --git a/src/Type/Type.php b/src/Type/Type.php index b49fedaa566..65b1c8e7e40 100644 --- a/src/Type/Type.php +++ b/src/Type/Type.php @@ -376,6 +376,13 @@ public function toNumber(): Type; /** Models the bitwise-not (`~$x`) operator. Returns `ErrorType` for types where `~` is undefined. */ public function toBitwiseNotType(): Type; + /** + * Models `get_class($x)`'s return type per leaf: definite objects yield + * their `class-string` projection, definite non-objects yield `false`, + * and possibly-objects yield the union of both. + */ + public function toGetClassResultType(): Type; + /** Models the (int) cast. */ public function toInteger(): Type; diff --git a/src/Type/UnionType.php b/src/Type/UnionType.php index 573c6b7863f..c016cc4f038 100644 --- a/src/Type/UnionType.php +++ b/src/Type/UnionType.php @@ -1092,6 +1092,11 @@ public function toBitwiseNotType(): Type return $this->unionTypes(static fn (Type $type): Type => $type->toBitwiseNotType()); } + public function toGetClassResultType(): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->toGetClassResultType()); + } + public function toAbsoluteNumber(): Type { $type = $this->unionTypes(static fn (Type $type): Type => $type->toAbsoluteNumber()); From d96a81f7030920ca8bd69ebd8c2aa9e888f3c7c6 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 7 May 2026 17:08:47 +0200 Subject: [PATCH 3/5] Extract `Type::toClassConstantType()` for `$x::class` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces `InitializerExprTypeResolver::getClassConstFetchTypeByReflection()`'s hand-rolled `TypeTraverser::map` for the `::class` constant projection with a polymorphic `Type` method. Per leaf: - `NullType` (incl. `TemplateNullType` via `isNull()` check): pass through (`null::class` reads as `null` in PHPStan's modeling). - Definite-non-object types (`NonObjectTypeTrait`, `MaybeObjectTypeTrait`, `MixedType`, `StrictMixedType`): `ErrorType`. - `ObjectType` and `ObjectTypeTrait` users (and `ClosureType` via its inner `ObjectType`): if the class is known and `isFinalByKeyword()`, return the literal class name (`ConstantStringType($name, true)`); otherwise `IntersectionType[ClassString, AccessoryLiteralStringType]`. - `EnumCaseObjectType`: explicitly skips the finality collapse and always returns `class-string&literal-string` — even though PHP enums report as `final`, the case-binding shape is what call sites expect. - `StaticType`: uses its own `getClassStringType()` so static binding is preserved (`class-string` / `class-string<$this>`). - `NonexistentParentClassType`: `class-string&literal-string` (matches the original "isObject->yes, no class names" branch). - All template variants (via `TemplateTypeTrait`): `class-string& literal-string`, with the same final-class collapse when the bound class is final. Required because templates with non-object bounds (e.g. `T of mixed`) fall through `MaybeObjectTypeTrait`'s `ErrorType` default and would otherwise lose the `class-string` shape. - `NeverType`: pass through. - `UnionType`/`IntersectionType`: distribute, threading the `ReflectionProvider` through. - `LateResolvableTypeTrait`: delegates to `resolve()`. The `ReflectionProvider` is passed as a method argument because the final-class collapse can only be decided at the `ReflectionProvider` level (matches the `Type::getCallableParametersAcceptors($scope)` precedent for dependency-carrying Type methods). Pure refactor: full test suite + phpstan + cs pass. --- .../InitializerExprTypeResolver.php | 50 +----------------- src/Type/ClosureType.php | 6 +++ src/Type/Enum/EnumCaseObjectType.php | 14 +++++ src/Type/Generic/TemplateTypeTrait.php | 30 +++++++++++ src/Type/IntersectionType.php | 6 +++ src/Type/MixedType.php | 8 +++ src/Type/NeverType.php | 6 +++ src/Type/NonexistentParentClassType.php | 7 +++ src/Type/NullType.php | 8 +++ src/Type/ObjectType.php | 14 +++++ src/Type/StaticType.php | 10 ++++ src/Type/StrictMixedType.php | 6 +++ src/Type/ThisType.php | 16 ++++++ src/Type/Traits/LateResolvableTypeTrait.php | 6 +++ src/Type/Traits/MaybeObjectTypeTrait.php | 7 +++ src/Type/Traits/NonObjectTypeTrait.php | 6 +++ src/Type/Traits/ObjectTypeTrait.php | 18 +++++++ src/Type/Type.php | 10 ++++ src/Type/UnionType.php | 6 +++ .../nsrt/class-constant-narrowing.php | 52 +++++++++++++++++++ 20 files changed, 237 insertions(+), 49 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/class-constant-narrowing.php diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index aff125305a6..495b8266664 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -93,7 +93,6 @@ use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypehintHelper; use PHPStan\Type\TypeResult; -use PHPStan\Type\TypeTraverser; use PHPStan\Type\TypeUtils; use PHPStan\Type\TypeWithClassName; use PHPStan\Type\UnionType; @@ -2451,54 +2450,7 @@ public function getClassConstFetchTypeByReflection(Name|Expr $class, string $con } if (strtolower($constantName) === 'class') { - return TypeTraverser::map( - $constantClassType, - function (Type $type, callable $traverse): Type { - if ($type instanceof UnionType || $type instanceof IntersectionType) { - return $traverse($type); - } - - if ($type instanceof NullType) { - return $type; - } - - if ($type instanceof EnumCaseObjectType) { - return new IntersectionType([ - new GenericClassStringType(new ObjectType($type->getClassName())), - new AccessoryLiteralStringType(), - ]); - } - - $objectClassNames = $type->getObjectClassNames(); - if (count($objectClassNames) > 1) { - throw new ShouldNotHappenException(); - } - - if ($type instanceof TemplateType && $objectClassNames === []) { - return new IntersectionType([ - new GenericClassStringType($type), - new AccessoryLiteralStringType(), - ]); - } elseif ($objectClassNames !== [] && $this->getReflectionProvider()->hasClass($objectClassNames[0])) { - $reflection = $this->getReflectionProvider()->getClass($objectClassNames[0]); - if ($reflection->isFinalByKeyword()) { - return new ConstantStringType($reflection->getName(), true); - } - - return new IntersectionType([ - new GenericClassStringType($type), - new AccessoryLiteralStringType(), - ]); - } elseif ($type->isObject()->yes()) { - return new IntersectionType([ - new ClassStringType(), - new AccessoryLiteralStringType(), - ]); - } - - return new ErrorType(); - }, - ); + return $constantClassType->toClassConstantType($this->getReflectionProvider()); } if ($constantClassType->isClassString()->yes()) { diff --git a/src/Type/ClosureType.php b/src/Type/ClosureType.php index 48efd92a6c5..ea8dd8204de 100644 --- a/src/Type/ClosureType.php +++ b/src/Type/ClosureType.php @@ -30,6 +30,7 @@ use PHPStan\Reflection\PassedByReference; use PHPStan\Reflection\Php\ClosureCallUnresolvedMethodPrototypeReflection; use PHPStan\Reflection\Php\DummyParameter; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\Reflection\Type\UnresolvedMethodPrototypeReflection; use PHPStan\Reflection\Type\UnresolvedPropertyPrototypeReflection; use PHPStan\TrinaryLogic; @@ -520,6 +521,11 @@ public function toGetClassResultType(): Type return $this->getClassStringType(); } + public function toClassConstantType(ReflectionProvider $reflectionProvider): Type + { + return $this->objectType->toClassConstantType($reflectionProvider); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/Enum/EnumCaseObjectType.php b/src/Type/Enum/EnumCaseObjectType.php index b77b957f3b2..5922bfe882b 100644 --- a/src/Type/Enum/EnumCaseObjectType.php +++ b/src/Type/Enum/EnumCaseObjectType.php @@ -11,14 +11,17 @@ use PHPStan\Reflection\ExtendedPropertyReflection; use PHPStan\Reflection\Php\EnumPropertyReflection; use PHPStan\Reflection\Php\EnumUnresolvedPropertyPrototypeReflection; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\Reflection\Type\UnresolvedPropertyPrototypeReflection; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\AcceptsResult; +use PHPStan\Type\Accessory\AccessoryLiteralStringType; use PHPStan\Type\CompoundType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\GeneralizePrecision; use PHPStan\Type\Generic\GenericClassStringType; +use PHPStan\Type\IntersectionType; use PHPStan\Type\IsSuperTypeOfResult; use PHPStan\Type\NeverType; use PHPStan\Type\ObjectType; @@ -228,6 +231,17 @@ public function getClassStringType(): Type return new GenericClassStringType(new ObjectType($this->getClassName())); } + public function toClassConstantType(ReflectionProvider $reflectionProvider): Type + { + // Enum cases always read their `::class` as the bare enum class + // name. Skip the parent's finality collapse: even though enum + // classes are reported as `final` by reflection, `Foo::Bar::class` + // in user code should resolve to `class-string&literal-string`, + // not the literal `'Foo'`, to keep the case-binding visible at + // downstream sites. + return new IntersectionType([$this->getClassStringType(), new AccessoryLiteralStringType()]); + } + public function toPhpDocNode(): TypeNode { return new ConstTypeNode( diff --git a/src/Type/Generic/TemplateTypeTrait.php b/src/Type/Generic/TemplateTypeTrait.php index d4ed8baf373..694dd1d61af 100644 --- a/src/Type/Generic/TemplateTypeTrait.php +++ b/src/Type/Generic/TemplateTypeTrait.php @@ -4,13 +4,17 @@ use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\TrinaryLogic; use PHPStan\Type\AcceptsResult; +use PHPStan\Type\Accessory\AccessoryLiteralStringType; +use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\ErrorType; use PHPStan\Type\IntersectionType; use PHPStan\Type\IsSuperTypeOfResult; use PHPStan\Type\MixedType; use PHPStan\Type\NeverType; +use PHPStan\Type\NullType; use PHPStan\Type\RecursionGuard; use PHPStan\Type\SubtractableType; use PHPStan\Type\Type; @@ -264,6 +268,32 @@ public function toCoercedArgumentType(bool $strictTypes): Type return $this; } + public function toClassConstantType(ReflectionProvider $reflectionProvider): Type + { + // `T::class` keeps the template variable visible — express the + // result as `class-string&literal-string` rather than the bound + // class, regardless of whether `T` has an object bound or not. + // Only when the bound is a known final class does the result + // collapse to the literal class name (the bound is the only + // possible substitution in that case). + if ($this->isNull()->yes()) { + return new NullType(); + } + + $classNames = $this->getObjectClassNames(); + if (count($classNames) === 1 && $reflectionProvider->hasClass($classNames[0])) { + $reflection = $reflectionProvider->getClass($classNames[0]); + if ($reflection->isFinalByKeyword()) { + return new ConstantStringType($reflection->getName(), true); + } + } + + return new IntersectionType([ + new GenericClassStringType($this), + new AccessoryLiteralStringType(), + ]); + } + public function inferTemplateTypes(Type $receivedType): TemplateTypeMap { if ( diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 4e7eeefaa9a..2c9b795e81f 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -19,6 +19,7 @@ use PHPStan\Reflection\MissingMethodFromReflectionException; use PHPStan\Reflection\MissingPropertyFromReflectionException; use PHPStan\Reflection\ParametersAcceptorSelector; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\Reflection\TrivialParametersAcceptor; use PHPStan\Reflection\Type\IntersectionTypeUnresolvedMethodPrototypeReflection; use PHPStan\Reflection\Type\IntersectionTypeUnresolvedPropertyPrototypeReflection; @@ -1421,6 +1422,11 @@ public function toGetClassResultType(): Type return $this->intersectTypes(static fn (Type $type): Type => $type->toGetClassResultType()); } + public function toClassConstantType(ReflectionProvider $reflectionProvider): Type + { + return $this->intersectTypes(static fn (Type $type): Type => $type->toClassConstantType($reflectionProvider)); + } + public function toAbsoluteNumber(): Type { $type = $this->intersectTypes(static fn (Type $type): Type => $type->toAbsoluteNumber()); diff --git a/src/Type/MixedType.php b/src/Type/MixedType.php index 49025134e65..cc3d4a47bee 100644 --- a/src/Type/MixedType.php +++ b/src/Type/MixedType.php @@ -13,6 +13,7 @@ use PHPStan\Reflection\Dummy\DummyPropertyReflection; use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedPropertyReflection; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\Reflection\TrivialParametersAcceptor; use PHPStan\Reflection\Type\CallbackUnresolvedMethodPrototypeReflection; use PHPStan\Reflection\Type\CallbackUnresolvedPropertyPrototypeReflection; @@ -634,6 +635,13 @@ public function toGetClassResultType(): Type return new UnionType([$classString, new ConstantBooleanType(false)]); } + public function toClassConstantType(ReflectionProvider $reflectionProvider): Type + { + // `mixed::class` is undefined — the original `TypeTraverser` cb fell + // through to `ErrorType` for any leaf that wasn't a definite object. + return new ErrorType(); + } + public function toAbsoluteNumber(): Type { return $this->toNumber()->toAbsoluteNumber(); diff --git a/src/Type/NeverType.php b/src/Type/NeverType.php index 5187168d9b9..6728221471a 100644 --- a/src/Type/NeverType.php +++ b/src/Type/NeverType.php @@ -9,6 +9,7 @@ use PHPStan\Reflection\ClassMemberAccessAnswerer; use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedPropertyReflection; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\Reflection\Type\UnresolvedMethodPrototypeReflection; use PHPStan\Reflection\Type\UnresolvedPropertyPrototypeReflection; use PHPStan\ShouldNotHappenException; @@ -444,6 +445,11 @@ public function toGetClassResultType(): Type return $this; } + public function toClassConstantType(ReflectionProvider $reflectionProvider): Type + { + return $this; + } + public function toAbsoluteNumber(): Type { return $this; diff --git a/src/Type/NonexistentParentClassType.php b/src/Type/NonexistentParentClassType.php index ee622d03fb7..45bd3cbe0d7 100644 --- a/src/Type/NonexistentParentClassType.php +++ b/src/Type/NonexistentParentClassType.php @@ -9,10 +9,12 @@ use PHPStan\Reflection\ClassMemberAccessAnswerer; use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedPropertyReflection; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\Reflection\Type\UnresolvedMethodPrototypeReflection; use PHPStan\Reflection\Type\UnresolvedPropertyPrototypeReflection; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; +use PHPStan\Type\Accessory\AccessoryLiteralStringType; use PHPStan\Type\Enum\EnumCaseObjectType; use PHPStan\Type\Traits\NonArrayTypeTrait; use PHPStan\Type\Traits\NonCallableTypeTrait; @@ -173,6 +175,11 @@ public function toGetClassResultType(): Type return $this->getClassStringType(); } + public function toClassConstantType(ReflectionProvider $reflectionProvider): Type + { + return new IntersectionType([$this->getClassStringType(), new AccessoryLiteralStringType()]); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/NullType.php b/src/Type/NullType.php index fe940866a15..db804ebba79 100644 --- a/src/Type/NullType.php +++ b/src/Type/NullType.php @@ -5,6 +5,7 @@ use PHPStan\Php\PhpVersion; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\TrinaryLogic; use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantBooleanType; @@ -149,6 +150,13 @@ public function toBitwiseNotType(): Type return new ErrorType(); } + public function toClassConstantType(ReflectionProvider $reflectionProvider): Type + { + // Null `::class` reads as `null` (mirrors how `null::class` flows + // through the `::class` resolution pipeline alongside object types). + return $this; + } + public function toAbsoluteNumber(): Type { return $this->toNumber()->toAbsoluteNumber(); diff --git a/src/Type/ObjectType.php b/src/Type/ObjectType.php index 2751fdaa7f4..42b4be0c6e3 100644 --- a/src/Type/ObjectType.php +++ b/src/Type/ObjectType.php @@ -23,6 +23,7 @@ use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedPropertyReflection; use PHPStan\Reflection\Php\UniversalObjectCratesClassReflectionExtension; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\Reflection\ReflectionProviderStaticAccessor; use PHPStan\Reflection\TrivialParametersAcceptor; use PHPStan\Reflection\Type\CallbackUnresolvedPropertyPrototypeReflection; @@ -33,6 +34,7 @@ use PHPStan\Reflection\Type\UnresolvedPropertyPrototypeReflection; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; +use PHPStan\Type\Accessory\AccessoryLiteralStringType; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; use PHPStan\Type\Accessory\AccessoryNumericStringType; use PHPStan\Type\Accessory\HasOffsetValueType; @@ -773,6 +775,18 @@ public function toGetClassResultType(): Type return $this->getClassStringType(); } + public function toClassConstantType(ReflectionProvider $reflectionProvider): Type + { + if ($reflectionProvider->hasClass($this->className)) { + $reflection = $reflectionProvider->getClass($this->className); + if ($reflection->isFinalByKeyword()) { + return new ConstantStringType($reflection->getName(), true); + } + } + + return new IntersectionType([$this->getClassStringType(), new AccessoryLiteralStringType()]); + } + public function toAbsoluteNumber(): Type { return $this->toNumber()->toAbsoluteNumber(); diff --git a/src/Type/StaticType.php b/src/Type/StaticType.php index d47eab8de3a..2c970a60b10 100644 --- a/src/Type/StaticType.php +++ b/src/Type/StaticType.php @@ -10,11 +10,13 @@ use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedPropertyReflection; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\Reflection\Type\CallbackUnresolvedMethodPrototypeReflection; use PHPStan\Reflection\Type\CallbackUnresolvedPropertyPrototypeReflection; use PHPStan\Reflection\Type\UnresolvedMethodPrototypeReflection; use PHPStan\Reflection\Type\UnresolvedPropertyPrototypeReflection; use PHPStan\TrinaryLogic; +use PHPStan\Type\Accessory\AccessoryLiteralStringType; use PHPStan\Type\Enum\EnumCaseObjectType; use PHPStan\Type\Generic\GenericClassStringType; use PHPStan\Type\Generic\GenericObjectType; @@ -767,6 +769,14 @@ public function toGetClassResultType(): Type return $this->getClassStringType(); } + public function toClassConstantType(ReflectionProvider $reflectionProvider): Type + { + // Like `toGetClassResultType()`, project through this `StaticType`'s + // own `getClassStringType()` so that `static::class` reads as + // `class-string` rather than the underlying class. + return new IntersectionType([$this->getClassStringType(), new AccessoryLiteralStringType()]); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/StrictMixedType.php b/src/Type/StrictMixedType.php index 49aa5740c79..907b6431138 100644 --- a/src/Type/StrictMixedType.php +++ b/src/Type/StrictMixedType.php @@ -9,6 +9,7 @@ use PHPStan\Reflection\ClassMemberAccessAnswerer; use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedPropertyReflection; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\Reflection\Type\UnresolvedMethodPrototypeReflection; use PHPStan\Reflection\Type\UnresolvedPropertyPrototypeReflection; use PHPStan\ShouldNotHappenException; @@ -412,6 +413,11 @@ public function toGetClassResultType(): Type return new ConstantBooleanType(false); } + public function toClassConstantType(ReflectionProvider $reflectionProvider): Type + { + return new ErrorType(); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/ThisType.php b/src/Type/ThisType.php index 39d4949ca8f..19a9fadda11 100644 --- a/src/Type/ThisType.php +++ b/src/Type/ThisType.php @@ -5,6 +5,8 @@ use PHPStan\PhpDocParser\Ast\Type\ThisTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\Reflection\ClassReflection; +use PHPStan\Reflection\ReflectionProvider; +use PHPStan\Type\Constant\ConstantStringType; use function sprintf; /** @api */ @@ -85,4 +87,18 @@ public function toPhpDocNode(): TypeNode return new ThisTypeNode(); } + public function toClassConstantType(ReflectionProvider $reflectionProvider): Type + { + // `$this` in a `final` class is pinned to that one class, so + // `$this::class` collapses to its literal name. For non-final + // classes `$this` could still be a subclass, so fall back to the + // `class-string<$this>` projection from the parent. + $reflection = $this->getClassReflection(); + if ($reflection->isFinalByKeyword()) { + return new ConstantStringType($reflection->getName(), true); + } + + return parent::toClassConstantType($reflectionProvider); + } + } diff --git a/src/Type/Traits/LateResolvableTypeTrait.php b/src/Type/Traits/LateResolvableTypeTrait.php index 8221f160c03..8f7b584e95b 100644 --- a/src/Type/Traits/LateResolvableTypeTrait.php +++ b/src/Type/Traits/LateResolvableTypeTrait.php @@ -7,6 +7,7 @@ use PHPStan\Reflection\ClassMemberAccessAnswerer; use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedPropertyReflection; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\Reflection\Type\UnresolvedMethodPrototypeReflection; use PHPStan\Reflection\Type\UnresolvedPropertyPrototypeReflection; use PHPStan\TrinaryLogic; @@ -429,6 +430,11 @@ public function toGetClassResultType(): Type return $this->resolve()->toGetClassResultType(); } + public function toClassConstantType(ReflectionProvider $reflectionProvider): Type + { + return $this->resolve()->toClassConstantType($reflectionProvider); + } + public function toAbsoluteNumber(): Type { return $this->resolve()->toAbsoluteNumber(); diff --git a/src/Type/Traits/MaybeObjectTypeTrait.php b/src/Type/Traits/MaybeObjectTypeTrait.php index 83f74b13c79..aed96f28073 100644 --- a/src/Type/Traits/MaybeObjectTypeTrait.php +++ b/src/Type/Traits/MaybeObjectTypeTrait.php @@ -9,6 +9,7 @@ use PHPStan\Reflection\Dummy\DummyPropertyReflection; use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedPropertyReflection; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\Reflection\Type\CallbackUnresolvedMethodPrototypeReflection; use PHPStan\Reflection\Type\CallbackUnresolvedPropertyPrototypeReflection; use PHPStan\Reflection\Type\UnresolvedMethodPrototypeReflection; @@ -16,6 +17,7 @@ use PHPStan\TrinaryLogic; use PHPStan\Type\ClassStringType; use PHPStan\Type\Constant\ConstantBooleanType; +use PHPStan\Type\ErrorType; use PHPStan\Type\MixedType; use PHPStan\Type\Type; use PHPStan\Type\UnionType; @@ -43,6 +45,11 @@ public function toGetClassResultType(): Type return new UnionType([$this->getClassStringType(), new ConstantBooleanType(false)]); } + public function toClassConstantType(ReflectionProvider $reflectionProvider): Type + { + return new ErrorType(); + } + public function isEnum(): TrinaryLogic { return TrinaryLogic::createMaybe(); diff --git a/src/Type/Traits/NonObjectTypeTrait.php b/src/Type/Traits/NonObjectTypeTrait.php index c47a798cc02..1b599481615 100644 --- a/src/Type/Traits/NonObjectTypeTrait.php +++ b/src/Type/Traits/NonObjectTypeTrait.php @@ -6,6 +6,7 @@ use PHPStan\Reflection\ClassMemberAccessAnswerer; use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedPropertyReflection; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\Reflection\Type\UnresolvedMethodPrototypeReflection; use PHPStan\Reflection\Type\UnresolvedPropertyPrototypeReflection; use PHPStan\ShouldNotHappenException; @@ -33,6 +34,11 @@ public function toGetClassResultType(): Type return new ConstantBooleanType(false); } + public function toClassConstantType(ReflectionProvider $reflectionProvider): Type + { + return new ErrorType(); + } + public function isEnum(): TrinaryLogic { return TrinaryLogic::createNo(); diff --git a/src/Type/Traits/ObjectTypeTrait.php b/src/Type/Traits/ObjectTypeTrait.php index 5e9344df11f..92c56c858f8 100644 --- a/src/Type/Traits/ObjectTypeTrait.php +++ b/src/Type/Traits/ObjectTypeTrait.php @@ -10,17 +10,22 @@ use PHPStan\Reflection\Dummy\DummyPropertyReflection; use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedPropertyReflection; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\Reflection\Type\CallbackUnresolvedMethodPrototypeReflection; use PHPStan\Reflection\Type\CallbackUnresolvedPropertyPrototypeReflection; use PHPStan\Reflection\Type\UnresolvedMethodPrototypeReflection; use PHPStan\Reflection\Type\UnresolvedPropertyPrototypeReflection; use PHPStan\TrinaryLogic; +use PHPStan\Type\Accessory\AccessoryLiteralStringType; use PHPStan\Type\ArrayType; use PHPStan\Type\BooleanType; +use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\ErrorType; +use PHPStan\Type\IntersectionType; use PHPStan\Type\MixedType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use function count; trait ObjectTypeTrait { @@ -46,6 +51,19 @@ public function toGetClassResultType(): Type return $this->getClassStringType(); } + public function toClassConstantType(ReflectionProvider $reflectionProvider): Type + { + $classNames = $this->getObjectClassNames(); + if (count($classNames) === 1 && $reflectionProvider->hasClass($classNames[0])) { + $reflection = $reflectionProvider->getClass($classNames[0]); + if ($reflection->isFinalByKeyword()) { + return new ConstantStringType($reflection->getName(), true); + } + } + + return new IntersectionType([$this->getClassStringType(), new AccessoryLiteralStringType()]); + } + public function isEnum(): TrinaryLogic { return TrinaryLogic::createMaybe(); diff --git a/src/Type/Type.php b/src/Type/Type.php index 65b1c8e7e40..0c464fddb44 100644 --- a/src/Type/Type.php +++ b/src/Type/Type.php @@ -10,6 +10,7 @@ use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedPropertyReflection; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\Reflection\Type\UnresolvedMethodPrototypeReflection; use PHPStan\Reflection\Type\UnresolvedPropertyPrototypeReflection; use PHPStan\TrinaryLogic; @@ -383,6 +384,15 @@ public function toBitwiseNotType(): Type; */ public function toGetClassResultType(): Type; + /** + * Models the type of `$x::class`. For known final classes the literal + * class name is returned; for everything else an + * `IntersectionType[ClassString, AccessoryLiteralStringType]`. + * `NullType` passes through (mirrors PHP's nullsafe `::class` semantics). + * `ReflectionProvider` is needed for the final-class lookup. + */ + public function toClassConstantType(ReflectionProvider $reflectionProvider): Type; + /** Models the (int) cast. */ public function toInteger(): Type; diff --git a/src/Type/UnionType.php b/src/Type/UnionType.php index c016cc4f038..ddbc12002e3 100644 --- a/src/Type/UnionType.php +++ b/src/Type/UnionType.php @@ -17,6 +17,7 @@ use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Reflection\MissingMethodFromReflectionException; use PHPStan\Reflection\MissingPropertyFromReflectionException; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\Reflection\Type\UnionTypeUnresolvedMethodPrototypeReflection; use PHPStan\Reflection\Type\UnionTypeUnresolvedPropertyPrototypeReflection; use PHPStan\Reflection\Type\UnresolvedMethodPrototypeReflection; @@ -1097,6 +1098,11 @@ public function toGetClassResultType(): Type return $this->unionTypes(static fn (Type $type): Type => $type->toGetClassResultType()); } + public function toClassConstantType(ReflectionProvider $reflectionProvider): Type + { + return $this->unionTypes(static fn (Type $type): Type => $type->toClassConstantType($reflectionProvider)); + } + public function toAbsoluteNumber(): Type { $type = $this->unionTypes(static fn (Type $type): Type => $type->toAbsoluteNumber()); diff --git a/tests/PHPStan/Analyser/nsrt/class-constant-narrowing.php b/tests/PHPStan/Analyser/nsrt/class-constant-narrowing.php new file mode 100644 index 00000000000..69ae05995d3 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/class-constant-narrowing.php @@ -0,0 +1,52 @@ +&literal-string', $other::class); + + if ($this::class !== $other::class) { + return false; + } + + assertType("'ClassConstantNarrowing\\\\SingleType'", $this::class); + assertType("'ClassConstantNarrowing\\\\SingleType'", static::class); + assertType("'ClassConstantNarrowing\\\\SingleType'", $other::class); + + return $this->name === $other->name; + } + +} + +class NonFinal +{ + + public function compare(self $other): bool + { + assertType('class-string<$this(ClassConstantNarrowing\NonFinal)>&literal-string', $this::class); + assertType('class-string', static::class); + assertType('class-string&literal-string', $other::class); + + return $this::class === $other::class; + } + +} From effa498e5d95530781f9648d2c35d619c2305df5 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 7 May 2026 17:17:32 +0200 Subject: [PATCH 4/5] Extract `Type::toObjectTypeForInstanceofCheck()` for `instanceof` RHS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the duplicate `TypeTraverser::map` callback in `TypeSpecifier::specifyTypesInCondition()` (the `instanceof ` branch) and the `InstanceOfClassTypeTraverser` helper used by `InstanceofHandler` with one polymorphic `Type` method. Both sites needed an out-of-band by-ref `$uncertainty` flag to carry "we kept this symbolically, don't decide yes/no definitively"; the new method returns a small `ClassNameToObjectTypeResult` value object (`Type $type, bool $uncertainty`) so each leaf can carry its own answer through composite-type distribution. Per leaf: - `ObjectType` and `ObjectTypeTrait` users (and `ClosureType`, `NonexistentParentClassType`, `StaticType`): keep `$this` as the comparison target and mark uncertain — the runtime class is only known when `instanceof` actually executes. - `GenericClassStringType`: project to `getGenericType()` and mark uncertain (the class name is symbolic). - `ConstantStringType`: collapse to `new ObjectType($value)` with no uncertainty (the class name is concrete). - All other non-objects (via `NonObjectTypeTrait`, `MaybeObjectTypeTrait`): `MixedType`, no uncertainty (matches the original `return new MixedType()` fallback). - `MixedType` / `StrictMixedType`: same `MixedType` fallback. - `NeverType`: pass through. - `UnionType`/`IntersectionType`: distribute, OR-folding the uncertainty across members (matches the original closure-captured behavior). - `LateResolvableTypeTrait`: delegate to `resolve()`. `InstanceOfClassTypeTraverser` is removed (no longer used). Pure refactor: full test suite + phpstan + cs pass. --- .../ExprHandler/InstanceofHandler.php | 8 ++-- .../InstanceOfClassTypeTraverser.php | 47 ------------------- src/Analyser/TypeSpecifier.php | 21 ++------- src/Type/ClassNameToObjectTypeResult.php | 24 ++++++++++ src/Type/ClosureType.php | 5 ++ src/Type/Constant/ConstantStringType.php | 6 +++ src/Type/Generic/GenericClassStringType.php | 10 ++++ src/Type/IntersectionType.php | 17 +++++++ src/Type/MixedType.php | 5 ++ src/Type/NeverType.php | 5 ++ src/Type/NonexistentParentClassType.php | 5 ++ src/Type/ObjectType.php | 5 ++ src/Type/StaticType.php | 5 ++ src/Type/StrictMixedType.php | 5 ++ src/Type/Traits/LateResolvableTypeTrait.php | 6 +++ src/Type/Traits/MaybeObjectTypeTrait.php | 6 +++ src/Type/Traits/NonObjectTypeTrait.php | 7 +++ src/Type/Traits/ObjectTypeTrait.php | 11 +++++ src/Type/Type.php | 10 ++++ src/Type/UnionType.php | 17 +++++++ 20 files changed, 155 insertions(+), 70 deletions(-) delete mode 100644 src/Analyser/Traverser/InstanceOfClassTypeTraverser.php create mode 100644 src/Type/ClassNameToObjectTypeResult.php diff --git a/src/Analyser/ExprHandler/InstanceofHandler.php b/src/Analyser/ExprHandler/InstanceofHandler.php index 59d09b1c9a0..ffc2045c58b 100644 --- a/src/Analyser/ExprHandler/InstanceofHandler.php +++ b/src/Analyser/ExprHandler/InstanceofHandler.php @@ -12,7 +12,6 @@ use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Traverser\InstanceOfClassTypeTraverser; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; @@ -21,7 +20,6 @@ use PHPStan\Type\ObjectType; use PHPStan\Type\StaticType; use PHPStan\Type\Type; -use PHPStan\Type\TypeTraverser; use PHPStan\Type\TypeUtils; use function array_merge; use function strtolower; @@ -94,9 +92,9 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type } } else { $classType = $scope->getType($expr->class); - $traverser = new InstanceOfClassTypeTraverser(); - $classType = TypeTraverser::map($classType, $traverser); - $uncertainty = $traverser->getUncertainty(); + $result = $classType->toObjectTypeForInstanceofCheck(); + $classType = $result->type; + $uncertainty = $result->uncertainty; } if ($classType->isSuperTypeOf(new MixedType())->yes()) { diff --git a/src/Analyser/Traverser/InstanceOfClassTypeTraverser.php b/src/Analyser/Traverser/InstanceOfClassTypeTraverser.php deleted file mode 100644 index b5426ff7a08..00000000000 --- a/src/Analyser/Traverser/InstanceOfClassTypeTraverser.php +++ /dev/null @@ -1,47 +0,0 @@ -getObjectClassNames() !== []) { - $this->uncertainty = true; - return $type; - } - if ($type instanceof GenericClassStringType) { - $this->uncertainty = true; - return $type->getGenericType(); - } - if ($type instanceof ConstantStringType) { - return new ObjectType($type->getValue()); - } - return new MixedType(); - } - - public function getUncertainty(): bool - { - return $this->uncertainty; - } - -} diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 972c5cd7d3b..973826839cf 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -165,24 +165,9 @@ public function specifyTypesInCondition( } $classType = $scope->getType($expr->class); - $uncertainty = false; - $type = TypeTraverser::map($classType, static function (Type $type, callable $traverse) use (&$uncertainty): Type { - if ($type instanceof UnionType || $type instanceof IntersectionType) { - return $traverse($type); - } - if ($type->getObjectClassNames() !== []) { - $uncertainty = true; - return $type; - } - if ($type instanceof GenericClassStringType) { - $uncertainty = true; - return $type->getGenericType(); - } - if ($type instanceof ConstantStringType) { - return new ObjectType($type->getValue()); - } - return new MixedType(); - }); + $result = $classType->toObjectTypeForInstanceofCheck(); + $type = $result->type; + $uncertainty = $result->uncertainty; if (!$type->isSuperTypeOf(new MixedType())->yes()) { if ($context->true()) { diff --git a/src/Type/ClassNameToObjectTypeResult.php b/src/Type/ClassNameToObjectTypeResult.php new file mode 100644 index 00000000000..2bb0695488c --- /dev/null +++ b/src/Type/ClassNameToObjectTypeResult.php @@ -0,0 +1,24 @@ +objectType->toClassConstantType($reflectionProvider); } + public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult + { + return new ClassNameToObjectTypeResult($this, true); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/Constant/ConstantStringType.php b/src/Type/Constant/ConstantStringType.php index ecfbc91ed7a..fa5ead3f09c 100644 --- a/src/Type/Constant/ConstantStringType.php +++ b/src/Type/Constant/ConstantStringType.php @@ -25,6 +25,7 @@ use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; use PHPStan\Type\Accessory\AccessoryNumericStringType; use PHPStan\Type\Accessory\AccessoryUppercaseStringType; +use PHPStan\Type\ClassNameToObjectTypeResult; use PHPStan\Type\ClassStringType; use PHPStan\Type\CompoundType; use PHPStan\Type\ConstantScalarType; @@ -293,6 +294,11 @@ public function toBitwiseNotType(): Type return new ConstantStringType(~$this->value); } + public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult + { + return new ClassNameToObjectTypeResult(new ObjectType($this->value), false); + } + public function toAbsoluteNumber(): Type { return $this->toNumber()->toAbsoluteNumber(); diff --git a/src/Type/Generic/GenericClassStringType.php b/src/Type/Generic/GenericClassStringType.php index 3123e533d51..01810be8ba7 100644 --- a/src/Type/Generic/GenericClassStringType.php +++ b/src/Type/Generic/GenericClassStringType.php @@ -7,6 +7,7 @@ use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\Reflection\ReflectionProviderStaticAccessor; use PHPStan\Type\AcceptsResult; +use PHPStan\Type\ClassNameToObjectTypeResult; use PHPStan\Type\ClassStringType; use PHPStan\Type\CompoundType; use PHPStan\Type\Constant\ConstantStringType; @@ -45,6 +46,15 @@ public function getGenericType(): Type return $this->type; } + public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult + { + // `class-string` narrows to `X` for the comparison target, but + // the actual runtime class can be any subclass of `X` — keep + // uncertainty so the caller falls back to `BooleanType` instead + // of a definite yes when `$x instanceof Y` and `Y === X`. + return new ClassNameToObjectTypeResult($this->getGenericType(), true); + } + public function getClassStringObjectType(): Type { return $this->getGenericType(); diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 2c9b795e81f..b5cbd6328d0 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -1427,6 +1427,23 @@ public function toClassConstantType(ReflectionProvider $reflectionProvider): Typ return $this->intersectTypes(static fn (Type $type): Type => $type->toClassConstantType($reflectionProvider)); } + public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult + { + $types = []; + $uncertainty = false; + foreach ($this->getTypes() as $innerType) { + $result = $innerType->toObjectTypeForInstanceofCheck(); + $types[] = $result->type; + if (!$result->uncertainty) { + continue; + } + + $uncertainty = true; + } + + return new ClassNameToObjectTypeResult(TypeCombinator::intersect(...$types), $uncertainty); + } + public function toAbsoluteNumber(): Type { $type = $this->intersectTypes(static fn (Type $type): Type => $type->toAbsoluteNumber()); diff --git a/src/Type/MixedType.php b/src/Type/MixedType.php index cc3d4a47bee..816d9199f43 100644 --- a/src/Type/MixedType.php +++ b/src/Type/MixedType.php @@ -642,6 +642,11 @@ public function toClassConstantType(ReflectionProvider $reflectionProvider): Typ return new ErrorType(); } + public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult + { + return new ClassNameToObjectTypeResult(new MixedType(), false); + } + public function toAbsoluteNumber(): Type { return $this->toNumber()->toAbsoluteNumber(); diff --git a/src/Type/NeverType.php b/src/Type/NeverType.php index 6728221471a..0e3f0e12fd3 100644 --- a/src/Type/NeverType.php +++ b/src/Type/NeverType.php @@ -450,6 +450,11 @@ public function toClassConstantType(ReflectionProvider $reflectionProvider): Typ return $this; } + public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult + { + return new ClassNameToObjectTypeResult($this, false); + } + public function toAbsoluteNumber(): Type { return $this; diff --git a/src/Type/NonexistentParentClassType.php b/src/Type/NonexistentParentClassType.php index 45bd3cbe0d7..4b9fd8cf373 100644 --- a/src/Type/NonexistentParentClassType.php +++ b/src/Type/NonexistentParentClassType.php @@ -180,6 +180,11 @@ public function toClassConstantType(ReflectionProvider $reflectionProvider): Typ return new IntersectionType([$this->getClassStringType(), new AccessoryLiteralStringType()]); } + public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult + { + return new ClassNameToObjectTypeResult($this, true); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/ObjectType.php b/src/Type/ObjectType.php index 42b4be0c6e3..e34e41c3a4a 100644 --- a/src/Type/ObjectType.php +++ b/src/Type/ObjectType.php @@ -787,6 +787,11 @@ public function toClassConstantType(ReflectionProvider $reflectionProvider): Typ return new IntersectionType([$this->getClassStringType(), new AccessoryLiteralStringType()]); } + public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult + { + return new ClassNameToObjectTypeResult($this, true); + } + public function toAbsoluteNumber(): Type { return $this->toNumber()->toAbsoluteNumber(); diff --git a/src/Type/StaticType.php b/src/Type/StaticType.php index 2c970a60b10..42e0891992e 100644 --- a/src/Type/StaticType.php +++ b/src/Type/StaticType.php @@ -777,6 +777,11 @@ public function toClassConstantType(ReflectionProvider $reflectionProvider): Typ return new IntersectionType([$this->getClassStringType(), new AccessoryLiteralStringType()]); } + public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult + { + return new ClassNameToObjectTypeResult($this, true); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/StrictMixedType.php b/src/Type/StrictMixedType.php index 907b6431138..3da525430ed 100644 --- a/src/Type/StrictMixedType.php +++ b/src/Type/StrictMixedType.php @@ -418,6 +418,11 @@ public function toClassConstantType(ReflectionProvider $reflectionProvider): Typ return new ErrorType(); } + public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult + { + return new ClassNameToObjectTypeResult(new MixedType(), false); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/Traits/LateResolvableTypeTrait.php b/src/Type/Traits/LateResolvableTypeTrait.php index 8f7b584e95b..946e3b279ce 100644 --- a/src/Type/Traits/LateResolvableTypeTrait.php +++ b/src/Type/Traits/LateResolvableTypeTrait.php @@ -13,6 +13,7 @@ use PHPStan\TrinaryLogic; use PHPStan\Type\AcceptsResult; use PHPStan\Type\BooleanType; +use PHPStan\Type\ClassNameToObjectTypeResult; use PHPStan\Type\CompoundType; use PHPStan\Type\Enum\EnumCaseObjectType; use PHPStan\Type\Generic\TemplateTypeMap; @@ -435,6 +436,11 @@ public function toClassConstantType(ReflectionProvider $reflectionProvider): Typ return $this->resolve()->toClassConstantType($reflectionProvider); } + public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult + { + return $this->resolve()->toObjectTypeForInstanceofCheck(); + } + public function toAbsoluteNumber(): Type { return $this->resolve()->toAbsoluteNumber(); diff --git a/src/Type/Traits/MaybeObjectTypeTrait.php b/src/Type/Traits/MaybeObjectTypeTrait.php index aed96f28073..ad3cb5dbfc5 100644 --- a/src/Type/Traits/MaybeObjectTypeTrait.php +++ b/src/Type/Traits/MaybeObjectTypeTrait.php @@ -15,6 +15,7 @@ use PHPStan\Reflection\Type\UnresolvedMethodPrototypeReflection; use PHPStan\Reflection\Type\UnresolvedPropertyPrototypeReflection; use PHPStan\TrinaryLogic; +use PHPStan\Type\ClassNameToObjectTypeResult; use PHPStan\Type\ClassStringType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\ErrorType; @@ -50,6 +51,11 @@ public function toClassConstantType(ReflectionProvider $reflectionProvider): Typ return new ErrorType(); } + public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult + { + return new ClassNameToObjectTypeResult(new MixedType(), false); + } + public function isEnum(): TrinaryLogic { return TrinaryLogic::createMaybe(); diff --git a/src/Type/Traits/NonObjectTypeTrait.php b/src/Type/Traits/NonObjectTypeTrait.php index 1b599481615..2570e203035 100644 --- a/src/Type/Traits/NonObjectTypeTrait.php +++ b/src/Type/Traits/NonObjectTypeTrait.php @@ -11,9 +11,11 @@ use PHPStan\Reflection\Type\UnresolvedPropertyPrototypeReflection; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; +use PHPStan\Type\ClassNameToObjectTypeResult; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Enum\EnumCaseObjectType; use PHPStan\Type\ErrorType; +use PHPStan\Type\MixedType; use PHPStan\Type\Type; trait NonObjectTypeTrait @@ -39,6 +41,11 @@ public function toClassConstantType(ReflectionProvider $reflectionProvider): Typ return new ErrorType(); } + public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult + { + return new ClassNameToObjectTypeResult(new MixedType(), false); + } + public function isEnum(): TrinaryLogic { return TrinaryLogic::createNo(); diff --git a/src/Type/Traits/ObjectTypeTrait.php b/src/Type/Traits/ObjectTypeTrait.php index 92c56c858f8..0a695ae212c 100644 --- a/src/Type/Traits/ObjectTypeTrait.php +++ b/src/Type/Traits/ObjectTypeTrait.php @@ -19,6 +19,7 @@ use PHPStan\Type\Accessory\AccessoryLiteralStringType; use PHPStan\Type\ArrayType; use PHPStan\Type\BooleanType; +use PHPStan\Type\ClassNameToObjectTypeResult; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\ErrorType; use PHPStan\Type\IntersectionType; @@ -64,6 +65,16 @@ public function toClassConstantType(ReflectionProvider $reflectionProvider): Typ return new IntersectionType([$this->getClassStringType(), new AccessoryLiteralStringType()]); } + public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult + { + // Definite-object types keep themselves as the comparison target — + // they're already an `ObjectType` (or its accessory). The + // uncertainty flag is set because the `instanceof` decision can + // only be made at runtime when comparing against a class name + // computed from the value. + return new ClassNameToObjectTypeResult($this, true); + } + public function isEnum(): TrinaryLogic { return TrinaryLogic::createMaybe(); diff --git a/src/Type/Type.php b/src/Type/Type.php index 0c464fddb44..1ecfd1754f8 100644 --- a/src/Type/Type.php +++ b/src/Type/Type.php @@ -393,6 +393,16 @@ public function toGetClassResultType(): Type; */ public function toClassConstantType(ReflectionProvider $reflectionProvider): Type; + /** + * Projects a class-name-or-object `Type` (the right-hand side of + * `$x instanceof `) to the `ObjectType` it should be compared + * against. Constant class strings collapse to their `ObjectType` + * exactly; everything kept symbolically (object class names, + * `class-string`) carries an uncertainty flag so the caller can + * fall back to `BooleanType` instead of a definite yes/no. + */ + public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult; + /** Models the (int) cast. */ public function toInteger(): Type; diff --git a/src/Type/UnionType.php b/src/Type/UnionType.php index ddbc12002e3..4ea71c34336 100644 --- a/src/Type/UnionType.php +++ b/src/Type/UnionType.php @@ -1103,6 +1103,23 @@ public function toClassConstantType(ReflectionProvider $reflectionProvider): Typ return $this->unionTypes(static fn (Type $type): Type => $type->toClassConstantType($reflectionProvider)); } + public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult + { + $types = []; + $uncertainty = false; + foreach ($this->getTypes() as $innerType) { + $result = $innerType->toObjectTypeForInstanceofCheck(); + $types[] = $result->type; + if (!$result->uncertainty) { + continue; + } + + $uncertainty = true; + } + + return new ClassNameToObjectTypeResult(TypeCombinator::union(...$types), $uncertainty); + } + public function toAbsoluteNumber(): Type { $type = $this->unionTypes(static fn (Type $type): Type => $type->toAbsoluteNumber()); From 9545e40ce10f199ae8163ec130e5dae7d9a6b829 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 7 May 2026 17:25:00 +0200 Subject: [PATCH 5/5] Extract `Type::toObjectTypeForIsACheck()` for `is_a($x, $class, $allowString)` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces `IsAFunctionTypeSpecifyingHelper::determineType()`'s hand-rolled `TypeTraverser::map` callback with a polymorphic `Type` method. The helper shrinks to a few lines that: - call `$classType->toObjectTypeForIsACheck($objectOrClassType, ...)`, - OR-fold the polymorphic uncertainty with the original "no constant strings in input" initial state, - run the same false-positive suppression check. Per leaf: - `ConstantStringType`: the only branch with real logic — collapses to `NeverType` when the input is the same final class (`!$allowSameClass`), sets uncertainty when the same class name appears in the input's class names (or when `$allowString` matches the input's superclass), then projects to `ObjectType($value)` (or `ObjectType|class-string` if `$allowString`). - `GenericClassStringType`: projects to its `getGenericType()` (or a union with the class-string itself if `$allowString`), no uncertainty. - All other leaves (default in `NonObjectTypeTrait`, `MaybeObjectTypeTrait`, `ObjectTypeTrait`, plus direct overrides on `ObjectType`, `StaticType`, `ClosureType`, `NonexistentParentClassType`, `MixedType`, `StrictMixedType`): `ObjectWithoutClassType` (or `ObjectWithoutClassType|class-string` if `$allowString`), no uncertainty. - `NeverType`: pass through. - `UnionType`/`IntersectionType`: distribute, OR-folding uncertainty. - `LateResolvableTypeTrait`: delegate to `resolve()`. Reuses the `ClassNameToObjectTypeResult` value object introduced for `toObjectTypeForInstanceofCheck()`. Pure refactor: full test suite + phpstan + cs pass. --- src/Type/ClosureType.php | 12 +++ src/Type/Constant/ConstantStringType.php | 54 +++++++++++ src/Type/Generic/GenericClassStringType.php | 12 +++ src/Type/IntersectionType.php | 17 ++++ src/Type/MixedType.php | 12 +++ src/Type/NeverType.php | 5 + src/Type/NonexistentParentClassType.php | 12 +++ src/Type/ObjectType.php | 12 +++ .../Php/IsAFunctionTypeSpecifyingHelper.php | 93 ++----------------- src/Type/StaticType.php | 12 +++ src/Type/StrictMixedType.php | 12 +++ src/Type/Traits/LateResolvableTypeTrait.php | 5 + src/Type/Traits/MaybeObjectTypeTrait.php | 13 +++ src/Type/Traits/NonObjectTypeTrait.php | 15 +++ src/Type/Traits/ObjectTypeTrait.php | 15 +++ src/Type/Type.php | 11 +++ src/Type/UnionType.php | 17 ++++ 17 files changed, 244 insertions(+), 85 deletions(-) diff --git a/src/Type/ClosureType.php b/src/Type/ClosureType.php index 28c436cafe0..a819526021f 100644 --- a/src/Type/ClosureType.php +++ b/src/Type/ClosureType.php @@ -531,6 +531,18 @@ public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult return new ClassNameToObjectTypeResult($this, true); } + public function toObjectTypeForIsACheck(Type $objectOrClassType, bool $allowString, bool $allowSameClass): ClassNameToObjectTypeResult + { + if ($allowString) { + return new ClassNameToObjectTypeResult( + new UnionType([new ObjectWithoutClassType(), new ClassStringType()]), + false, + ); + } + + return new ClassNameToObjectTypeResult(new ObjectWithoutClassType(), false); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/Constant/ConstantStringType.php b/src/Type/Constant/ConstantStringType.php index fa5ead3f09c..23401fac77f 100644 --- a/src/Type/Constant/ConstantStringType.php +++ b/src/Type/Constant/ConstantStringType.php @@ -37,6 +37,7 @@ use PHPStan\Type\IntersectionType; use PHPStan\Type\IsSuperTypeOfResult; use PHPStan\Type\MixedType; +use PHPStan\Type\NeverType; use PHPStan\Type\NullType; use PHPStan\Type\ObjectType; use PHPStan\Type\StaticType; @@ -44,8 +45,11 @@ use PHPStan\Type\Traits\ConstantScalarTypeTrait; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; use function addcslashes; +use function array_unique; +use function array_values; use function in_array; use function is_float; use function is_int; @@ -299,6 +303,56 @@ public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult return new ClassNameToObjectTypeResult(new ObjectType($this->value), false); } + public function toObjectTypeForIsACheck(Type $objectOrClassType, bool $allowString, bool $allowSameClass): ClassNameToObjectTypeResult + { + $objectOrClassTypeClassNames = $objectOrClassType->getObjectClassNames(); + if ($allowString) { + foreach ($objectOrClassType->getConstantStrings() as $constantString) { + $objectOrClassTypeClassNames[] = $constantString->getValue(); + } + $objectOrClassTypeClassNames = array_values(array_unique($objectOrClassTypeClassNames)); + } + + $uncertainty = false; + if (!$allowSameClass) { + if ($objectOrClassTypeClassNames === [$this->value]) { + $isSameClass = true; + foreach ($objectOrClassType->getObjectClassReflections() as $classReflection) { + if (!$classReflection->isFinal()) { + $isSameClass = false; + break; + } + } + + if ($isSameClass) { + return new ClassNameToObjectTypeResult(new NeverType(), false); + } + } + + if ( + // For object, as soon as the exact same type is provided + // in the list we cannot be sure of the result + in_array($this->value, $objectOrClassTypeClassNames, true) + // This also occurs for generic class string + || ($allowString && $objectOrClassTypeClassNames === [] && $objectOrClassType->isSuperTypeOf($this)->yes()) + ) { + $uncertainty = true; + } + } + + if ($allowString) { + return new ClassNameToObjectTypeResult( + new UnionType([ + new ObjectType($this->value), + new GenericClassStringType(new ObjectType($this->value)), + ]), + $uncertainty, + ); + } + + return new ClassNameToObjectTypeResult(new ObjectType($this->value), $uncertainty); + } + public function toAbsoluteNumber(): Type { return $this->toNumber()->toAbsoluteNumber(); diff --git a/src/Type/Generic/GenericClassStringType.php b/src/Type/Generic/GenericClassStringType.php index 01810be8ba7..a76e1cb8151 100644 --- a/src/Type/Generic/GenericClassStringType.php +++ b/src/Type/Generic/GenericClassStringType.php @@ -55,6 +55,18 @@ public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult return new ClassNameToObjectTypeResult($this->getGenericType(), true); } + public function toObjectTypeForIsACheck(Type $objectOrClassType, bool $allowString, bool $allowSameClass): ClassNameToObjectTypeResult + { + if ($allowString) { + return new ClassNameToObjectTypeResult( + TypeCombinator::union($this->getGenericType(), $this), + false, + ); + } + + return new ClassNameToObjectTypeResult($this->getGenericType(), false); + } + public function getClassStringObjectType(): Type { return $this->getGenericType(); diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index b5cbd6328d0..4b45e2453a4 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -1444,6 +1444,23 @@ public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult return new ClassNameToObjectTypeResult(TypeCombinator::intersect(...$types), $uncertainty); } + public function toObjectTypeForIsACheck(Type $objectOrClassType, bool $allowString, bool $allowSameClass): ClassNameToObjectTypeResult + { + $types = []; + $uncertainty = false; + foreach ($this->getTypes() as $innerType) { + $result = $innerType->toObjectTypeForIsACheck($objectOrClassType, $allowString, $allowSameClass); + $types[] = $result->type; + if (!$result->uncertainty) { + continue; + } + + $uncertainty = true; + } + + return new ClassNameToObjectTypeResult(TypeCombinator::intersect(...$types), $uncertainty); + } + public function toAbsoluteNumber(): Type { $type = $this->intersectTypes(static fn (Type $type): Type => $type->toAbsoluteNumber()); diff --git a/src/Type/MixedType.php b/src/Type/MixedType.php index 816d9199f43..8fee88984df 100644 --- a/src/Type/MixedType.php +++ b/src/Type/MixedType.php @@ -647,6 +647,18 @@ public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult return new ClassNameToObjectTypeResult(new MixedType(), false); } + public function toObjectTypeForIsACheck(Type $objectOrClassType, bool $allowString, bool $allowSameClass): ClassNameToObjectTypeResult + { + if ($allowString) { + return new ClassNameToObjectTypeResult( + new UnionType([new ObjectWithoutClassType(), new ClassStringType()]), + false, + ); + } + + return new ClassNameToObjectTypeResult(new ObjectWithoutClassType(), false); + } + public function toAbsoluteNumber(): Type { return $this->toNumber()->toAbsoluteNumber(); diff --git a/src/Type/NeverType.php b/src/Type/NeverType.php index 0e3f0e12fd3..c73d5813a62 100644 --- a/src/Type/NeverType.php +++ b/src/Type/NeverType.php @@ -455,6 +455,11 @@ public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult return new ClassNameToObjectTypeResult($this, false); } + public function toObjectTypeForIsACheck(Type $objectOrClassType, bool $allowString, bool $allowSameClass): ClassNameToObjectTypeResult + { + return new ClassNameToObjectTypeResult($this, false); + } + public function toAbsoluteNumber(): Type { return $this; diff --git a/src/Type/NonexistentParentClassType.php b/src/Type/NonexistentParentClassType.php index 4b9fd8cf373..23eae76a12a 100644 --- a/src/Type/NonexistentParentClassType.php +++ b/src/Type/NonexistentParentClassType.php @@ -185,6 +185,18 @@ public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult return new ClassNameToObjectTypeResult($this, true); } + public function toObjectTypeForIsACheck(Type $objectOrClassType, bool $allowString, bool $allowSameClass): ClassNameToObjectTypeResult + { + if ($allowString) { + return new ClassNameToObjectTypeResult( + new UnionType([new ObjectWithoutClassType(), new ClassStringType()]), + false, + ); + } + + return new ClassNameToObjectTypeResult(new ObjectWithoutClassType(), false); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/ObjectType.php b/src/Type/ObjectType.php index e34e41c3a4a..7fc355d3ed0 100644 --- a/src/Type/ObjectType.php +++ b/src/Type/ObjectType.php @@ -792,6 +792,18 @@ public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult return new ClassNameToObjectTypeResult($this, true); } + public function toObjectTypeForIsACheck(Type $objectOrClassType, bool $allowString, bool $allowSameClass): ClassNameToObjectTypeResult + { + if ($allowString) { + return new ClassNameToObjectTypeResult( + new UnionType([new ObjectWithoutClassType(), new ClassStringType()]), + false, + ); + } + + return new ClassNameToObjectTypeResult(new ObjectWithoutClassType(), false); + } + public function toAbsoluteNumber(): Type { return $this->toNumber()->toAbsoluteNumber(); diff --git a/src/Type/Php/IsAFunctionTypeSpecifyingHelper.php b/src/Type/Php/IsAFunctionTypeSpecifyingHelper.php index 603d9a15cc2..3af3ef9ba28 100644 --- a/src/Type/Php/IsAFunctionTypeSpecifyingHelper.php +++ b/src/Type/Php/IsAFunctionTypeSpecifyingHelper.php @@ -3,20 +3,7 @@ namespace PHPStan\Type\Php; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Type\ClassStringType; -use PHPStan\Type\Constant\ConstantStringType; -use PHPStan\Type\Generic\GenericClassStringType; -use PHPStan\Type\IntersectionType; -use PHPStan\Type\NeverType; -use PHPStan\Type\ObjectType; -use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\Type; -use PHPStan\Type\TypeCombinator; -use PHPStan\Type\TypeTraverser; -use PHPStan\Type\UnionType; -use function array_unique; -use function array_values; -use function in_array; #[AutowiredService] final class IsAFunctionTypeSpecifyingHelper @@ -29,84 +16,20 @@ public function determineType( bool $allowSameClass, ): ?Type { - $objectOrClassTypeClassNames = $objectOrClassType->getObjectClassNames(); - if ($allowString) { - foreach ($objectOrClassType->getConstantStrings() as $constantString) { - $objectOrClassTypeClassNames[] = $constantString->getValue(); - } - $objectOrClassTypeClassNames = array_values(array_unique($objectOrClassTypeClassNames)); - } - - $isUncertain = $classType->getConstantStrings() === []; - - $resultType = TypeTraverser::map( - $classType, - static function (Type $type, callable $traverse) use ($objectOrClassType, $objectOrClassTypeClassNames, $allowString, $allowSameClass, &$isUncertain): Type { - if ($type instanceof UnionType || $type instanceof IntersectionType) { - return $traverse($type); - } - if ($type instanceof ConstantStringType) { - if (!$allowSameClass) { - if ($objectOrClassTypeClassNames === [$type->getValue()]) { - $isSameClass = true; - foreach ($objectOrClassType->getObjectClassReflections() as $classReflection) { - if (!$classReflection->isFinal()) { - $isSameClass = false; - break; - } - } - - if ($isSameClass) { - return new NeverType(); - } - } - - if ( - // For object, as soon as the exact same type is provided - // in the list we cannot be sure of the result - in_array($type->getValue(), $objectOrClassTypeClassNames, true) - // This also occurs for generic class string - || ($allowString && $objectOrClassTypeClassNames === [] && $objectOrClassType->isSuperTypeOf($type)->yes()) - ) { - $isUncertain = true; - } - } - if ($allowString) { - return new UnionType([ - new ObjectType($type->getValue()), - new GenericClassStringType(new ObjectType($type->getValue())), - ]); - } - - return new ObjectType($type->getValue()); - } - if ($type instanceof GenericClassStringType) { - if ($allowString) { - return TypeCombinator::union( - $type->getGenericType(), - $type, - ); - } - - return $type->getGenericType(); - } - if ($allowString) { - return new UnionType([ - new ObjectWithoutClassType(), - new ClassStringType(), - ]); - } + $result = $classType->toObjectTypeForIsACheck($objectOrClassType, $allowString, $allowSameClass); - return new ObjectWithoutClassType(); - }, - ); + // `getConstantStrings() === []` propagates uncertainty from + // the input as a whole — preserved from the original + // `$isUncertain` initial state to keep the false-positive + // suppression below identical. + $isUncertain = $result->uncertainty || $classType->getConstantStrings() === []; // prevent false-positives - if ($isUncertain && $resultType->isSuperTypeOf($objectOrClassType)->yes()) { + if ($isUncertain && $result->type->isSuperTypeOf($objectOrClassType)->yes()) { return null; } - return $resultType; + return $result->type; } } diff --git a/src/Type/StaticType.php b/src/Type/StaticType.php index 42e0891992e..ff646bb5332 100644 --- a/src/Type/StaticType.php +++ b/src/Type/StaticType.php @@ -782,6 +782,18 @@ public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult return new ClassNameToObjectTypeResult($this, true); } + public function toObjectTypeForIsACheck(Type $objectOrClassType, bool $allowString, bool $allowSameClass): ClassNameToObjectTypeResult + { + if ($allowString) { + return new ClassNameToObjectTypeResult( + new UnionType([new ObjectWithoutClassType(), new ClassStringType()]), + false, + ); + } + + return new ClassNameToObjectTypeResult(new ObjectWithoutClassType(), false); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/StrictMixedType.php b/src/Type/StrictMixedType.php index 3da525430ed..42ef8e71cc0 100644 --- a/src/Type/StrictMixedType.php +++ b/src/Type/StrictMixedType.php @@ -423,6 +423,18 @@ public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult return new ClassNameToObjectTypeResult(new MixedType(), false); } + public function toObjectTypeForIsACheck(Type $objectOrClassType, bool $allowString, bool $allowSameClass): ClassNameToObjectTypeResult + { + if ($allowString) { + return new ClassNameToObjectTypeResult( + new UnionType([new ObjectWithoutClassType(), new ClassStringType()]), + false, + ); + } + + return new ClassNameToObjectTypeResult(new ObjectWithoutClassType(), false); + } + public function toAbsoluteNumber(): Type { return new ErrorType(); diff --git a/src/Type/Traits/LateResolvableTypeTrait.php b/src/Type/Traits/LateResolvableTypeTrait.php index 946e3b279ce..e5d2b583737 100644 --- a/src/Type/Traits/LateResolvableTypeTrait.php +++ b/src/Type/Traits/LateResolvableTypeTrait.php @@ -441,6 +441,11 @@ public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult return $this->resolve()->toObjectTypeForInstanceofCheck(); } + public function toObjectTypeForIsACheck(Type $objectOrClassType, bool $allowString, bool $allowSameClass): ClassNameToObjectTypeResult + { + return $this->resolve()->toObjectTypeForIsACheck($objectOrClassType, $allowString, $allowSameClass); + } + public function toAbsoluteNumber(): Type { return $this->resolve()->toAbsoluteNumber(); diff --git a/src/Type/Traits/MaybeObjectTypeTrait.php b/src/Type/Traits/MaybeObjectTypeTrait.php index ad3cb5dbfc5..365af72a83e 100644 --- a/src/Type/Traits/MaybeObjectTypeTrait.php +++ b/src/Type/Traits/MaybeObjectTypeTrait.php @@ -20,6 +20,7 @@ use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\ErrorType; use PHPStan\Type\MixedType; +use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\Type; use PHPStan\Type\UnionType; @@ -56,6 +57,18 @@ public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult return new ClassNameToObjectTypeResult(new MixedType(), false); } + public function toObjectTypeForIsACheck(Type $objectOrClassType, bool $allowString, bool $allowSameClass): ClassNameToObjectTypeResult + { + if ($allowString) { + return new ClassNameToObjectTypeResult( + new UnionType([new ObjectWithoutClassType(), new ClassStringType()]), + false, + ); + } + + return new ClassNameToObjectTypeResult(new ObjectWithoutClassType(), false); + } + public function isEnum(): TrinaryLogic { return TrinaryLogic::createMaybe(); diff --git a/src/Type/Traits/NonObjectTypeTrait.php b/src/Type/Traits/NonObjectTypeTrait.php index 2570e203035..a5aad74a182 100644 --- a/src/Type/Traits/NonObjectTypeTrait.php +++ b/src/Type/Traits/NonObjectTypeTrait.php @@ -12,11 +12,14 @@ use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\ClassNameToObjectTypeResult; +use PHPStan\Type\ClassStringType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Enum\EnumCaseObjectType; use PHPStan\Type\ErrorType; use PHPStan\Type\MixedType; +use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\Type; +use PHPStan\Type\UnionType; trait NonObjectTypeTrait { @@ -46,6 +49,18 @@ public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult return new ClassNameToObjectTypeResult(new MixedType(), false); } + public function toObjectTypeForIsACheck(Type $objectOrClassType, bool $allowString, bool $allowSameClass): ClassNameToObjectTypeResult + { + if ($allowString) { + return new ClassNameToObjectTypeResult( + new UnionType([new ObjectWithoutClassType(), new ClassStringType()]), + false, + ); + } + + return new ClassNameToObjectTypeResult(new ObjectWithoutClassType(), false); + } + public function isEnum(): TrinaryLogic { return TrinaryLogic::createNo(); diff --git a/src/Type/Traits/ObjectTypeTrait.php b/src/Type/Traits/ObjectTypeTrait.php index 0a695ae212c..af087676454 100644 --- a/src/Type/Traits/ObjectTypeTrait.php +++ b/src/Type/Traits/ObjectTypeTrait.php @@ -20,12 +20,15 @@ use PHPStan\Type\ArrayType; use PHPStan\Type\BooleanType; use PHPStan\Type\ClassNameToObjectTypeResult; +use PHPStan\Type\ClassStringType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\ErrorType; use PHPStan\Type\IntersectionType; use PHPStan\Type\MixedType; +use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\UnionType; use function count; trait ObjectTypeTrait @@ -75,6 +78,18 @@ public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult return new ClassNameToObjectTypeResult($this, true); } + public function toObjectTypeForIsACheck(Type $objectOrClassType, bool $allowString, bool $allowSameClass): ClassNameToObjectTypeResult + { + if ($allowString) { + return new ClassNameToObjectTypeResult( + new UnionType([new ObjectWithoutClassType(), new ClassStringType()]), + false, + ); + } + + return new ClassNameToObjectTypeResult(new ObjectWithoutClassType(), false); + } + public function isEnum(): TrinaryLogic { return TrinaryLogic::createMaybe(); diff --git a/src/Type/Type.php b/src/Type/Type.php index 1ecfd1754f8..9eed2d71408 100644 --- a/src/Type/Type.php +++ b/src/Type/Type.php @@ -403,6 +403,17 @@ public function toClassConstantType(ReflectionProvider $reflectionProvider): Typ */ public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult; + /** + * Projects a class-name-or-object `Type` (the second argument of + * `is_a($x, $class, $allow_string)`) to the `ObjectType` to narrow + * `$x` against. When `$allowString` is true, the `is_a()` result also + * keeps the original class-string accepted alongside the object. + * `$allowSameClass` controls whether matching the input's own class + * collapses to `NeverType` for final classes (the call site's + * "always-true" suppression). + */ + public function toObjectTypeForIsACheck(Type $objectOrClassType, bool $allowString, bool $allowSameClass): ClassNameToObjectTypeResult; + /** Models the (int) cast. */ public function toInteger(): Type; diff --git a/src/Type/UnionType.php b/src/Type/UnionType.php index 4ea71c34336..f69dff57684 100644 --- a/src/Type/UnionType.php +++ b/src/Type/UnionType.php @@ -1120,6 +1120,23 @@ public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult return new ClassNameToObjectTypeResult(TypeCombinator::union(...$types), $uncertainty); } + public function toObjectTypeForIsACheck(Type $objectOrClassType, bool $allowString, bool $allowSameClass): ClassNameToObjectTypeResult + { + $types = []; + $uncertainty = false; + foreach ($this->getTypes() as $innerType) { + $result = $innerType->toObjectTypeForIsACheck($objectOrClassType, $allowString, $allowSameClass); + $types[] = $result->type; + if (!$result->uncertainty) { + continue; + } + + $uncertainty = true; + } + + return new ClassNameToObjectTypeResult(TypeCombinator::union(...$types), $uncertainty); + } + public function toAbsoluteNumber(): Type { $type = $this->unionTypes(static fn (Type $type): Type => $type->toAbsoluteNumber());