Skip to content

Return never from sealed ObjectType::tryRemove() when a removed union member is a supertype, and from TemplateType::tryRemove() when the bound is emptied - #6802

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-82onm2k
Open

phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-82onm2k

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Since 2.3.0, a multi-catch such as catch (BaseEx | OtherEx $e) stopped catching BaseEx throw points when BaseEx is @phpstan-sealed. The result was false throws.void / missingType.checkedException errors. TypeCombinator::remove(BaseEx, BaseEx|OtherEx) returned BaseEx~BaseEx instead of never. Enums had the same problem: remove(SomeEnum, SomeEnum|stdClass).

Changes

  • src/Type/ObjectType.php (tryRemove(), sealed-hierarchy branch): return never when a member of the removed union is a supertype of $this.
  • src/Type/Generic/TemplateTypeTrait.php (tryRemove()): return never when removing the type from the bound leaves never. Without this, T of SomeEnum minus SomeEnum|stdClass was rebuilt as T of mixed. The template case reaches the same all-at-once path through TypeCombinator::canRemoveUnionAtOnce().
  • Native mirrors: turbo-ext/src/ObjectType.cpp, turbo-ext/src/TypeTraits.cpp.
  • Probed and found correct: union fromTypes (SomeEnum|int), StaticType, a sealed type with a subtracted case, multi-catch inside methods, catching a parent class or every allowed subtype, and instanceof ... || instanceof ... narrowing (which removes members one at a time).

Root cause

TypeCombinator::doRemove() removes a whole union from a sealed hierarchy in one tryRemove() call instead of member by member (introduced in #6443). The member-by-member path checks first whether the removed type is a supertype of the source type and returns never if so. The new ObjectType::tryRemove() branch skipped that check. It only subtracted the members that are subtypes of $this, and subtracting a type from itself produces X~X, not never. Template types bounded by a sealed class or enum delegate to the bound, so they took the same path. They also turned an emptied bound into mixed.

Test

  • tests/PHPStan/Rules/Exceptions/data/bug-15392.php with ThrowsVoidFunctionWithExplicitThrowPointRuleTest::testBug15392: the reproducer from the issue, with the catch union in both orders.
  • TypeCombinatorTest::dataRemove: removing SomeEnum|stdClass and stdClass|SomeEnum from SomeEnum gives never, and so does removing it from T of SomeEnum. stdClass|SomeEnumInterface is kept as a guard for the interface (supertype) case.

Fixes phpstan/phpstan#15392

🤖 Generated with Claude Code

…nion member is a supertype, and from `TemplateType::tryRemove()` when the bound is emptied

- ObjectType::tryRemove(): the sealed-hierarchy branch that removes a whole
  union at once only collected members `$this` is a supertype of and
  subtracted them, so removing `BaseEx|OtherEx` from a sealed `BaseEx`
  produced `BaseEx~BaseEx` instead of `never` (unlike `TypeCombinator::remove()`
  with a single type). A multi-catch `catch (BaseEx | OtherEx $e)` therefore
  did not catch a `BaseEx` throw point. Return `never` when a member is a
  supertype of `$this`. Enums take the same path (`SomeEnum|stdClass`).
- TemplateTypeTrait::tryRemove(): removing the same union from `T of SomeEnum`
  now empties the bound, and rebuilding the template with a `never` bound
  turned it into `T of mixed`. Return `never` instead: T is a subset of its bound.
- Both changes are mirrored in the turbo extension
  (turbo-ext/src/ObjectType.cpp, turbo-ext/src/TypeTraits.cpp).
- Probed and found correct: union `fromType`s (`SomeEnum|int`), `StaticType`,
  a sealed type with a subtracted case, multi-catch in methods, catching a
  parent class or every allowed subtype, and narrowing with
  `instanceof ... || instanceof ...`, which removes members one at a time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

Multi-catch with a @phpstan-sealed exception class no longer catches that class (regression in 2.3.0)

1 participant