Skip to content

Keep never in MutatingScope::intersectButNotNever() instead of widening it to null - #6504

Merged
ondrejmirtes merged 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-1j5mp4v
Sep 21, 2026
Merged

ondrejmirtes merged 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-1j5mp4v

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

A closure or arrow function whose body never returns was inferred as Closure(): null instead of Closure(): never when its native return type contained null:

\PHPStan\dumpType(fn () => $this->redirect('/product'));        // Closure(): never
\PHPStan\dumpType(fn (): null => $this->redirect('/product'));  // Closure(): null  <-- wrong
\PHPStan\dumpType(fn (): int => throw new \LogicException());   // Closure(): never
\PHPStan\dumpType(fn (): ?int => throw new \LogicException());  // Closure(): null  <-- wrong

Passing such a closure where Closure(): never is expected was then reported as Parameter #2 of closure expects (Closure(): never)|null, Closure(): null given.

Changes

  • src/Analyser/MutatingScope.php — intersectButNotNever() now returns the intersection unchanged when it is never, before the native type's nullability is added back.

That single helper is used from six places, all of which were affected and are fixed:

  • src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php — closure return types (resolveClosureType()) and arrow function return types (resolveArrowFunctionReturnType()).
  • src/Analyser/MutatingScope.php — enterAnonymousFunctionWithoutReflection() and enterArrowFunctionWithoutReflection(), for both the PHPDoc and the native parameter type flavour.

Analogous cases probed and confirmed fixed by the same change:

  • static closures and static arrow functions (static fn (): ?int => throw ...).
  • Closures declared : ?Generator whose body only throws (the non-generator arm of the return type resolution).
  • Closure and arrow function parameters with a nullable native type in a callable(never): void context, e.g. array_map(fn (?int $i) => ..., $listOfNever).
  • The same via the variadic branch of getCallableParameterType(), i.e. callable(never...): void.

Analogous cases probed and found to be already correct (no change needed):

  • Nullsafe short-circuits — MethodCallHandler, StaticCallHandler, PropertyFetchHandler, StaticPropertyFetchHandler, ArrayDimFetchHandler and MutatingScope::resolveScopeStateType(). $a?->neverReturningMethod() is null, which is right: the expression only yields a value when $a is null.
  • ArrayFindFunctionReturnTypeExtension — array_find() over a list<never> is null, which is right.
  • TypehintHelper::decideType() — it has the same containsNull()/addNull() shape, but cannot reach it with a never result: an explicit @return never returns early, an implicit NeverType is never chosen as the result type, and a nullable native type is a UnionType, which takes the other branch.
  • ParserNodeTypeToPHPStanType::resolve(), PhpParameterReflection, PhpParameterFromParserNodeReflection, Php8SignatureMapProvider, PhpDocNodeResolver — only reachable with ?never / @param never $x = null, which are already reported as invalid or are more accurately null.

These were found by instrumenting TypeCombinator::addNull() to log every call that receives a NeverType together with its call site, and running the whole test suite.

Root cause

intersectButNotNever() intersects the native type with the inferred type and then adds null back when the native type is nullable, so that a native ?T does not lose its nullability to a non-nullable inferred type (see "Do not lose generic type when the closure has native return type", 7e9cd45).

It relied on the old behaviour of TypeCombinator::addNull(), which used NullType::isSuperTypeOf($type)->no() as its guard. Because never is a subtype of every type, that guard returned never unchanged. TypeCombinator::addNull() was recently changed to ask $type->isNull()->no() instead, which is no for never, so addNull(never) now produces null.

With a native ?int/null and an inferred never the intersection is never, and the nullability restoration then resurrected a value out of a type that says no value is ever produced. The addNull() change itself is right — it is what makes $a?->neverReturningMethod() and array_find() over an empty array correctly null. The fix therefore stays in the helper that restores nullability: a never intersection has nothing to restore nullability onto.

Test

  • tests/PHPStan/Analyser/nsrt/bug-15285.php — the playground reproducer, asserting Closure(): never for fn (), fn (): null, fn (): int, fn (): ?int and for the equivalent anonymous functions, plus the analogous cases: static closures and arrow functions, : ?Generator closures (both the throwing and the yielding one), nullable closure and arrow function parameters under array_map() over a list<never>, and under a callable(never...): void.
  • tests/PHPStan/Rules/Functions/data/bug-15285.php and CallCallablesRuleTest::testBug15285() — the reported false positive, expecting no errors.

Both fail before the change (Closure(): null / never parameters inferred as null, and the Parameter #2 of closure expects (Closure(): never)|null, Closure(): null given. error) and pass after it.

Fixes phpstan/phpstan#15285

…dening it to `null`

* `intersectButNotNever()` restores the native type's nullability onto the
  intersection result. It relied on the old `TypeCombinator::addNull()`
  behaviour, where `addNull(never)` returned `never`. Since `addNull()` was
  improved it returns `null` for `never`, so a nullable native type combined
  with an inferred `never` produced `null`.
* Return the intersection unchanged as soon as it is `never`, before the
  nullability is added back.
* Fixes closure and arrow function return types resolved in
  `ClosureTypeResolver` (`fn (): null => neverReturning()` and
  `function (): ?int { throw ...; }` are `Closure(): never` again), including
  the `static` variants and closures declared `: ?Generator` whose body only
  throws.
* Fixes the same helper's other call sites - closure and arrow function
  parameter types in `MutatingScope::enterAnonymousFunctionWithoutReflection()`
  and `enterArrowFunctionWithoutReflection()`. A `?int` parameter in a
  `callable(never): void` context (e.g. `array_map()` over `list<never>`) is
  `never` inside the body again, for both the fixed and the variadic parameter
  branch.
* Probed the other `TypeCombinator::addNull()` call sites that can receive a
  `never` by instrumenting `addNull()` and running the whole test suite. The
  nullsafe short-circuit sites (`MethodCallHandler`, `StaticCallHandler`,
  `PropertyFetchHandler`, `StaticPropertyFetchHandler`, `ArrayDimFetchHandler`,
  `MutatingScope::resolveScopeStateType()`) and
  `ArrayFindFunctionReturnTypeExtension` are correct as they are: there `null`
  really is the result of the short circuit / of not finding anything.
  `TypehintHelper::decideType()` cannot reach its `addNull()` with a `never`,
  because an explicit `@return never` returns early and an implicit one is
  never picked as the result type.
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.

Closure with a nullable native return type and a never-returning body is inferred as Closure(): null instead of Closure(): never

2 participants