Repository navigation
Keep never in MutatingScope::intersectButNotNever() instead of widening it to null - #6504
Merged
ondrejmirtes merged 1 commit intoSep 21, 2026
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A closure or arrow function whose body never returns was inferred as
Closure(): nullinstead ofClosure(): neverwhen its native return type containednull:Passing such a closure where
Closure(): neveris expected was then reported asParameter #2 of closure expects (Closure(): never)|null, Closure(): null given.Changes
src/Analyser/MutatingScope.php—intersectButNotNever()now returns the intersection unchanged when it isnever, 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()andenterArrowFunctionWithoutReflection(), for both the PHPDoc and the native parameter type flavour.Analogous cases probed and confirmed fixed by the same change:
staticclosures andstaticarrow functions (static fn (): ?int => throw ...).: ?Generatorwhose body only throws (the non-generator arm of the return type resolution).callable(never): voidcontext, e.g.array_map(fn (?int $i) => ..., $listOfNever).getCallableParameterType(), i.e.callable(never...): void.Analogous cases probed and found to be already correct (no change needed):
MethodCallHandler,StaticCallHandler,PropertyFetchHandler,StaticPropertyFetchHandler,ArrayDimFetchHandlerandMutatingScope::resolveScopeStateType().$a?->neverReturningMethod()isnull, which is right: the expression only yields a value when$aisnull.ArrayFindFunctionReturnTypeExtension—array_find()over alist<never>isnull, which is right.TypehintHelper::decideType()— it has the samecontainsNull()/addNull()shape, but cannot reach it with aneverresult: an explicit@return neverreturns early, an implicitNeverTypeis never chosen as the result type, and a nullable native type is aUnionType, 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 accuratelynull.These were found by instrumenting
TypeCombinator::addNull()to log every call that receives aNeverTypetogether with its call site, and running the whole test suite.Root cause
intersectButNotNever()intersects the native type with the inferred type and then addsnullback when the native type is nullable, so that a native?Tdoes 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 usedNullType::isSuperTypeOf($type)->no()as its guard. Becauseneveris a subtype of every type, that guard returnedneverunchanged.TypeCombinator::addNull()was recently changed to ask$type->isNull()->no()instead, which isnofornever, soaddNull(never)now producesnull.With a native
?int/nulland an inferredneverthe intersection isnever, and the nullability restoration then resurrected a value out of a type that says no value is ever produced. TheaddNull()change itself is right — it is what makes$a?->neverReturningMethod()andarray_find()over an empty array correctlynull. The fix therefore stays in the helper that restores nullability: aneverintersection has nothing to restore nullability onto.Test
tests/PHPStan/Analyser/nsrt/bug-15285.php— the playground reproducer, assertingClosure(): neverforfn (),fn (): null,fn (): int,fn (): ?intand for the equivalent anonymous functions, plus the analogous cases:staticclosures and arrow functions,: ?Generatorclosures (both the throwing and the yielding one), nullable closure and arrow function parameters underarray_map()over alist<never>, and under acallable(never...): void.tests/PHPStan/Rules/Functions/data/bug-15285.phpandCallCallablesRuleTest::testBug15285()— the reported false positive, expecting no errors.Both fail before the change (
Closure(): null/neverparameters inferred asnull, and theParameter #2 of closure expects (Closure(): never)|null, Closure(): null given.error) and pass after it.Fixes phpstan/phpstan#15285