Skip to content

Resolve conditional @throws types like ($x is 0 ? Exception : void) at call sites and throw points#5874

Open
phpstan-bot wants to merge 4 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-7619a59
Open

Resolve conditional @throws types like ($x is 0 ? Exception : void) at call sites and throw points#5874
phpstan-bot wants to merge 4 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-7619a59

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

PHPStan already supports conditional return types such as ($x is 0 ? never : float), but conditional @throws types were not supported: a @throws ($x is 0 ? Exception : void) declaration was rejected by InvalidThrowsPhpDocValueRule as "not subtype of Throwable", the function body was wrongly reported as missing the exception in its @throws, and callers never benefited from the per-argument precision.

This change makes conditional @throws types first-class:

  • The declaration is accepted when every branch is a valid throws type.
  • A caller that triggers the throwing branch (e.g. inverse(0)) is reported as missing the exception in its own @throws, while a caller that does not (e.g. inverse(7), or inverse($x) with $x of type int<3, 5>) is not.
  • The function body itself is matched against its conditional @throws, using the parameter narrowing at each throw point.

Changes

  • src/Analyser/ConditionalThrowTypeResolver.php (new): resolves ConditionalTypeForParameter inside a throws type.
    • resolveForCall() builds the parameter→argument-type map from a call's arguments (named, positional, variadic and default values) and converts the conditional via toConditional(), then resolves late-resolvable types.
    • resolveForScope() resolves the conditional against the parameter variables narrowed in a given scope (used inside the function body).
  • src/Analyser/ExprHandler/FuncCallHandler.php, src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php, src/Analyser/ExprHandler/NewHandler.php: resolve the reflection's throws type with resolveForCall() before turning it into a throw point, so function calls, method calls, static-method calls and new all honour conditional throws.
  • src/Rules/Exceptions/MissingCheckedExceptionInThrowsCheck.php: resolve the declared throws type per throw point with resolveForScope() (this check backs the function, method and property-hook missing-throws rules).
  • src/Rules/PhpDoc/InvalidThrowsPhpDocValueRule.php: recurse into ConditionalType/ConditionalTypeForParameter branches; void is accepted only standalone or inside a conditional branch (so Throwable|void stays invalid).

All resolution paths short-circuit on !$throwType->hasTemplateOrLateResolvableType(), so ordinary throws types are unaffected.

Root cause

A conditional @throws type is resolved into the same ConditionalTypeForParameter representation as a conditional return type, but nothing ever resolved it: throws types live on the function/method reflection and are read raw (getThrowType()), bypassing the call-site ResolvedFunctionVariant machinery that resolves conditional return types. As a result the conditional always collapsed to the Maybe-certain union of its branches (Exception|void), which is neither a clean Throwable (validation failure) nor a precise per-call result (no caller precision, false "missing" on the body).

The fix mirrors the conditional-return-type resolution for throws: at every place the raw throws type is consumed, the ConditionalTypeForParameter is resolved against the relevant subject — the call arguments at call sites, the narrowed parameter variables at throw points.

Test

  • tests/PHPStan/Rules/PhpDoc/InvalidThrowsPhpDocValueRuleTest: added valid conditional throws (($x is 0 ? Exception : void), ($x is 0 ? Exception : RuntimeException)) which produce no error, and an invalid-branch case (($x is 0 ? stdClass : void)) which is still reported.
  • tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInFunctionThrowsRuleTest::testConditionalThrows: a conditional-throwing inverse() function and callers — inverse(0) and inverse($x:int) are reported as missing the exception, while inverse(7) and inverse($x:int<3,5>) are not, and the body of inverse() itself is clean.
  • tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInMethodThrowsRuleTest::testConditionalThrows: the same behaviour for instance method calls, static method calls and constructor (new) calls (the analogous parallel constructs).

Fixes phpstan/phpstan#7906

…)` at call sites and throw points

- Add `ConditionalThrowTypeResolver` that resolves `ConditionalTypeForParameter`
  inside a `@throws` type, either against the arguments passed at a call site
  (`resolveForCall`) or against the parameter variables narrowed in a throw
  point's scope (`resolveForScope`).
- Resolve conditional throws types when computing throw points for function calls
  (`FuncCallHandler`), method/static calls (`MethodThrowPointHelper`) and
  constructor calls (`NewHandler`), so callers only see the exception when the
  arguments actually trigger the throwing branch.
- Resolve conditional throws types per throw point in
  `MissingCheckedExceptionInThrowsCheck` (used by the function, method and
  property-hook rules) so a function body that throws only in a branch is matched
  against its own conditional `@throws` declaration.
- Accept conditional throws types in `InvalidThrowsPhpDocValueRule` as long as
  every branch is a valid throws type (a `Throwable` subtype or `void`); plain
  `void` is still only allowed standalone or inside a conditional branch, so
  `Throwable|void` remains invalid.
@ondrejmirtes

Copy link
Copy Markdown
Member

Is it correctly remapped when a parameter name changes during PHPDoc inheritance?

@VincentLanglet VincentLanglet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

There is ConditionalTypeForParameter and ConditionalType with appropriate PhpDocParser Node

Wouldn't it better to introduce a

PHPStan\PhpDocParser\Ast\Type\ConditionalTypeForThrowsNode

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

I've now fully traced the flow and can address the review accurately. The reviewer's suggestion concerns the PhpDocParser AST layer, which lives in a separate package — let me lay out the analysis rather than fabricate a cross-repo change.

Response to the review

Thanks @VincentLanglet — I looked into whether a dedicated PHPStan\PhpDocParser\Ast\Type\ConditionalTypeForThrowsNode is the right layer for this, and there's a structural constraint worth flagging before going that route.

The phpdoc type parser is context-free. @throws is parsed by parseThrowsTagValue(), which just delegates to the generic typeParser->parse():

private function parseThrowsTagValue(TokenIterator $tokens): Ast\PhpDoc\ThrowsTagValueNode
{
    $type = $this->typeParser->parse($tokens);
    ...
}

So ($x is 0 ? Exception : void) produces the exact same ConditionalTypeForParameterNode whether it appears in @return, @param, or @throws — the parser has no idea which tag it's inside. To emit a ConditionalTypeForThrowsNode, phpstan/phpdoc-parser (a separate repo/package) would have to become throws-aware, either by special-casing the throws tag during parsing or by post-rewriting the node. That can't be done from phpstan-src — it can only consume node types the parser already ships.

What we'd gain vs. duplicate. The only genuinely throws-specific wrinkle today is that void is a legal branch (meaning "does not throw"), which is why InvalidThrowsPhpDocValueRule allows void only inside conditional branches. Everything else — toConditional(), late-resolvable resolution, TypeTraverser mapping in ConditionalThrowTypeResolver — is identical to how conditional return types already resolve and is verified correct. A dedicated node would fork that resolution path to get one void-handling distinction, while reusing ConditionalTypeForParameter keeps a single, shared, already-tested resolution path.

My recommendation: keep the ConditionalTypeForParameter representation at the Type level (the resolution semantics are genuinely the same as conditional return types), and contain the only throws-specific rule — void acceptance — where it already lives. If you'd prefer the AST-level distinction regardless, I'm glad to open a phpstan/phpdoc-parser PR adding ConditionalTypeForThrowsNode and the throws-aware parsing, then wire TypeNodeResolver/resolveThrowsTags through it here — but since it's a cross-repo change that forks the shared resolution, I wanted to confirm that's the direction you want before doing it.

Separately, on @ondrejmirtes's earlier question about parameter-name remapping during PHPDoc inheritance — that's a real concern with the current getParameterName()-keyed matching and is worth a dedicated test/fix; I'll address it as its own change since it's orthogonal to the node-type question.


I did not make code changes or commits for this review point, because the requested change (ConditionalTypeForThrowsNode) belongs in the separate phpstan/phpdoc-parser package and cannot be implemented from within phpstan-src — the type parser there is context-free and would need throws-awareness first. Pushing an empty or fabricated commit here would misrepresent that.

Two things I can act on next if you confirm:

  1. Cross-repo route: open a phpstan/phpdoc-parser PR for the dedicated node and wire it through here.
  2. Inheritance remapping: investigate and fix the parameter-name remapping concern raised by @ondrejmirtes (a concrete, phpstan-src-local change with its own test).

Want me to proceed with (1), (2), or both?

@VincentLanglet VincentLanglet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this should support ConditionalType too since the method could have a phpdoc like

@template TKey of int|string
@throws (TKey is int ? void : Exception)

* @param int $x
* @throws ($x is 0 ? Exception : void)
*/
public function inverse(int $x): float

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Add a test with

class Service2 extends Service
{
     public function inverse(int $y): float
}

To ensure the type is correctly resolved even if the method is overriden with a different name for the param.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done. Here's a summary of how I addressed the review.

Review request

VincentLanglet asked to add a test where Service2 extends Service overrides inverse() with a differently-named parameter ($y instead of $x), to ensure the conditional @throws type is still resolved correctly through inheritance. This is the same parameter-name remapping concern ondrejmirtes raised earlier.

What I found

Adding that test exposed a real bug. When a method inherits a parent's @throws PHPDoc, ResolvedPhpDocBlock::mergeThrowsTags() returned the parent's throws tag verbatim, without remapping parameter names — unlike mergeReturnTags(), which applies transformConditionalReturnTypeWithParameterNameMapping(). So a conditional throws like ($x is 0 ? Exception : void) inherited into inverse(int $y) still referenced $x, which:

  • made the overriding method's body wrongly report a missing @throws (the conditional couldn't resolve against the narrowed $y), and
  • broke call-site precision ($service2->inverse(0) not flagged, while a non-zero call could be flagged).

I confirmed the failure by stashing the fix and watching the test fail for exactly these reasons before re-applying it.

The fix

mergeThrowsTags() now takes the InheritedPhpDocParameterMapping and applies the same ConditionalTypeForParameter parameter-name remapping already used for inherited @return types — keeping a single shared resolution path rather than forking one.

Tests

Added Service2 extends Service with inverse(int $y) (renamed param, no own PHPDoc) plus two callers to conditional-throws-method.php. inheritedMethodCallZero() is now correctly reported as missing the exception; inheritedMethodCallNonZero() and the overriding body are clean.

I also resolved a rebase conflict with a concurrently-pushed commit (which added the template-based lookup() case for VincentLanglet's other review point), merging both sets of cases and fixing up the shifted line numbers.

make phpstan is green, and the exceptions + PhpDoc test suites pass. Commit a9304c028 is pushed.

}

if ($this->isThrowsValid($phpDocThrowsType)) {
if ($this->isThrowsValid($phpDocThrowsType, false)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The only place false is passed is here.

What about moving the

if ($phpDocThrowsType->isVoid()->yes()) {
			return [];
		}

check inside the method isThrowsValid and removing the voidAllowed param ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done. I addressed VincentLanglet's review comment on InvalidThrowsPhpDocValueRule.php.

What the reviewer asked

Move the if ($phpDocThrowsType->isVoid()->yes()) { return []; } check inside isThrowsValid() and remove the voidAllowed parameter, since false was only ever passed at the single top-level call.

What I changed

  • Removed the standalone void early-return from processNode() and the false argument; the call is now just $this->isThrowsValid($phpDocThrowsType).
  • Folded void handling into isThrowsValid(): a standalone void (or a conditional branch that is void) now returns true at the top of the method, and the voidAllowed parameter is gone.
  • Preserved the Throwable|void rejection: there was a subtlety the literal suggestion would have broken — Throwable|void and stdClass|void must stay invalid (asserted at lines 96/103 of the test). If void returned true universally, a union containing it would wrongly become valid. So the UnionType branch now explicitly rejects a void member ($innerType->isVoid()->yes() || !$this->isThrowsValid($innerType)).

This keeps the behavior identical: standalone void valid, void inside conditional branches valid, Throwable|void invalid, invalid conditional branches still reported.

Verification

  • InvalidThrowsPhpDocValueRuleTest — 7/7 pass
  • The two MissingCheckedException...ThrowsRuleTest suites — 6/6 pass
  • make phpstan — no errors

I rebased onto the meanwhile-pushed commit a9304c028 ("Remap parameter names in inherited conditional @throws types", which handles ondrejmirtes's earlier inheritance concern), re-ran the test green, and pushed as dc9159faf.

… ? void : Exception)`

Conditional `@throws` resolution previously only handled
`ConditionalTypeForParameter` (subject is a `$param` variable). A conditional
whose subject is a template type produces a `ConditionalType` instead, which was
never resolved against the inferred template type map.

`ConditionalThrowTypeResolver::resolveForCall()` now also resolves template
types via the call-site `resolvedTemplateTypeMap`/`callSiteVarianceMap` when the
parameters acceptor is an `ExtendedParametersAcceptor`, so callers see the
precise branch (e.g. `lookup(1)` does not throw, `lookup('foo')` throws). Inside
the function body, where the template is not bound to a concrete type,
`resolveForScope()` collapses the unresolvable conditional to the union of its
branches so the body's throw points are matched against the broadest declared
set instead of a `Maybe`-certain conditional (which caused a false positive).

`InvalidThrowsPhpDocValueRule` already recurses into `ConditionalType` branches,
so template-based conditional throws validation works (and still rejects an
invalid branch like `(TKey is int ? void : stdClass)`).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

I've addressed the review. Here's a summary of what I did.

Review point

VincentLanglet asked that the resolver also support ConditionalType (template-based conditional throws), e.g.:

@template TKey of int|string
@throws (TKey is int ? void : Exception)

This was a real gap: the resolver only handled ConditionalTypeForParameter (subject is a $param). A conditional whose subject is a template type parses into a ConditionalType, which was never resolved against the inferred template type map — so callers got no precision and the function body got a false "missing @throws".

Changes

src/Analyser/ConditionalThrowTypeResolver.php

  • resolveForCall() now, after the existing ConditionalTypeForParameter resolution, also resolves template types via the call-site resolvedTemplateTypeMap / callSiteVarianceMap (the same machinery ResolvedFunctionVariant uses for conditional return types) when the parameters acceptor is an ExtendedParametersAcceptor. So lookup(1) resolves to the void branch (no throw) while lookup('foo') resolves to the Exception branch (throws).
  • resolveForScope() now collapses an unresolvable template conditional to the union of its branches (resolveUnresolvableTypes: true) rather than leaving it as a Maybe-certain conditional. Inside the body the template isn't bound to a concrete type, and the previous Maybe made isSuperTypeOf(...).yes() fail, producing a false positive on the function's own throw point.

InvalidThrowsPhpDocValueRule already recurses into ConditionalType branches, so validation needed no change — it accepts the valid template conditional and still rejects an invalid branch like (TKey is int ? void : stdClass).

Tests

  • Function, method, static-method and constructor call sites with a template-based conditional throws (lookupInt → no error, lookupString/lookupUnknown → reported), plus a clean generic body.
  • Validation: a valid template conditional throws and an invalid-branch one ((TKey of int|string is int ? void : stdClass) reported).

make tests (exceptions + phpdoc suites) and make phpstan both pass. Committed and pushed.

One note I'll flag honestly: ondrejmirtes's earlier question about parameter-name remapping during PHPDoc inheritance is still orthogonal and not addressed here — happy to take that as a separate change if you'd like.

phpstan-bot and others added 2 commits June 14, 2026 20:51
When a method overrides a parent method using a different parameter name
and inherits the parent's @throws PHPDoc, a conditional throws type like
($x is 0 ? Exception : void) referenced the parent's parameter name. Apply
the same parameter-name remapping used for inherited @return types so the
conditional resolves against the overriding method's parameters both at the
throw points in the body and at call sites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The void-acceptance check previously lived both in processNode (for a
standalone `@throws void`) and as a `voidAllowed` flag threaded through
isThrowsValid. Since `voidAllowed` was only ever false at the single
top-level call, fold the standalone-void handling into isThrowsValid and
reject `void` only when it appears as a union member (e.g. Throwable|void).

Co-Authored-By: Claude Opus 4.8 <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.

Conditional throw-types

3 participants