Skip to content

Extract polymorphic Type methods for class-name narrowing#5612

Merged
ondrejmirtes merged 5 commits into2.1.xfrom
polymorphic-class-string-narrowing
May 7, 2026
Merged

Extract polymorphic Type methods for class-name narrowing#5612
ondrejmirtes merged 5 commits into2.1.xfrom
polymorphic-class-string-narrowing

Conversation

@ondrejmirtes
Copy link
Copy Markdown
Member

Summary

Pure refactor — no behaviour changes. Extracts five hand-rolled TypeTraverser::map callbacks into polymorphic Type methods.

Same motivation as #5611: each call site opened with composite-recursion boilerplate (if (\$type instanceof UnionType || \$type instanceof IntersectionType) { return \$traverse(\$type); }) and dispatched per-leaf via further instanceof checks. Polymorphic dispatch shrinks the call site to a one-liner and lets each Type impl give the right answer via its own override. Two of the five sites also relied on a by-ref &\$uncertainty closure capture; those return a small ClassNameToObjectTypeResult value object instead, so composite types can OR-fold the flag during distribution.

Type is @api-do-not-implement, so adding methods is BC-safe per the project's API promise.

Five new methods, one per commit:

Commit Method Replaces
1 Type::toBitwiseNotType(): Type InitializerExprTypeResolver::getBitwiseNotTypeFromType() (~\$x)
2 Type::toGetClassResultType(): Type GetClassDynamicReturnTypeExtension (get_class(\$x))
3 Type::toClassConstantType(ReflectionProvider): Type InitializerExprTypeResolver::getClassConstFetchTypeByReflection() (\$x::class)
4 Type::toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult TypeSpecifier:169 + InstanceOfClassTypeTraverser (deleted, also used by InstanceofHandler)
5 Type::toObjectTypeForIsACheck(Type, bool, bool): ClassNameToObjectTypeResult IsAFunctionTypeSpecifyingHelper::determineType()

The two value-object methods reuse the same ClassNameToObjectTypeResult (`Type $type, bool $uncertainty`); commit 4 introduces it.

Each method follows the standard plumbing pattern: declared on the Type interface, with default bodies in the relevant Object*Trait (Non / Maybe / ObjectTypeTrait), explicit overrides on the direct-implementing classes (ObjectType, StaticType, ClosureType, NonexistentParentClassType, MixedType, StrictMixedType, NeverType), and LateResolvableTypeTrait delegating via resolve(). Composite types (UnionType, IntersectionType) distribute. Where a leaf type needs special handling (ConstantStringType, GenericClassStringType, EnumCaseObjectType, NullType, all Template*Type variants via TemplateTypeTrait), it gets its own override.

InstanceOfClassTypeTraverser becomes dead code and is removed.

Net diff: +844/-257 across 49 files.

Test plan

  • `make tests` passes after each commit (12065 tests, 79675 assertions).
  • `make phpstan` passes after each commit.
  • `make cs` passes after each commit.
  • CI green.

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.
Replaces `GetClassDynamicReturnTypeExtension`'s hand-rolled
`TypeTraverser::map` with a polymorphic `Type` method. Each leaf
projects to:

- definite object: `class-string<X>` (via `$this->getClassStringType()`)
- definite non-object: `false`
- possibly-object: `class-string<X>|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<static>` /
  `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.
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<X>, AccessoryLiteralStringType]`.
- `EnumCaseObjectType`: explicitly skips the finality collapse and
  always returns `class-string<EnumName>&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<static>` / `class-string<$this>`).
- `NonexistentParentClassType`: `class-string&literal-string` (matches
  the original "isObject->yes, no class names" branch).
- All template variants (via `TemplateTypeTrait`): `class-string<T>&
  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<T>`
  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.
Replaces the duplicate `TypeTraverser::map` callback in
`TypeSpecifier::specifyTypesInCondition()` (the
`instanceof <expr>` 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.
…wString)`

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<X>` 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.
@ondrejmirtes ondrejmirtes force-pushed the polymorphic-class-string-narrowing branch from 03c5f6b to 9545e40 Compare May 7, 2026 18:54
@ondrejmirtes ondrejmirtes merged commit de99d53 into 2.1.x May 7, 2026
647 of 655 checks passed
@ondrejmirtes ondrejmirtes deleted the polymorphic-class-string-narrowing branch May 7, 2026 18:58
ondrejmirtes added a commit that referenced this pull request May 7, 2026
Follow-up cleanup across #5611, #5612, #5613:

- `FuncCallHandler::getArrayWalkResultType()` and
  `getArraySortDoNotPreserveListFunctionType()` were single-statement
  wrappers around `Type::mapValueType()` / `Type::makeListMaybe()`.
  Both private, both fully replaced by their polymorphic call;
  inline at the two/one call sites and delete the helpers.
- `InstanceofHandler` and `TypeSpecifier` (the `instanceof <expr>`
  branch) used a throwaway `$classType = $scope->getType(...)` only
  to immediately overwrite it with `$result->type`. Drop the
  intermediate; chain the call.

Pure simplification: full test suite + phpstan + cs pass.
ondrejmirtes added a commit that referenced this pull request May 7, 2026
Follow-up cleanup across #5611, #5612, #5613:

- `FuncCallHandler::getArrayWalkResultType()` and
  `getArraySortDoNotPreserveListFunctionType()` were single-statement
  wrappers around `Type::mapValueType()` / `Type::makeListMaybe()`.
  Both private, both fully replaced by their polymorphic call;
  inline at the two/one call sites and delete the helpers.
- `InstanceofHandler` and `TypeSpecifier` (the `instanceof <expr>`
  branch) used a throwaway `$classType = $scope->getType(...)` only
  to immediately overwrite it with `$result->type`. Drop the
  intermediate; chain the call.

Pure simplification: full test suite + phpstan + cs pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant