Add null to return type of Iterator::current(), Iterator::key(), and Generator::send() with valid() narrowing#5668
Conversation
…, and `Generator::send()` with `valid()` narrowing - Add `IteratorCurrentReturnTypeExtension` that appends `|null` to the return type of `Iterator::current()` and `Iterator::key()` via `DynamicMethodReturnTypeExtension`, since these methods can return null when the iterator is in an invalid state (exhausted, before rewind, etc.) - Add `GeneratorSendReturnTypeExtension` that appends `|null` to `Generator::send()` for the same reason (returns null when generator is exhausted) - Add `@phpstan-assert-if-true !null $this->current()` and `@phpstan-assert-if-true !null $this->key()` to `Iterator::valid()` in the stub, so that `null` is narrowed away after a `valid()` check - `foreach` loops are unaffected because they resolve value/key types via `getIterableValueType()`/`getIterableKeyType()` which use template types directly, not the method return types - Update existing tests that asserted the old (buggy) behavior without `valid()` checks
VincentLanglet
left a comment
There was a problem hiding this comment.
Ensure rewind and next reset the iterator value of key/current
if ($it->valid()) {
assertType('string', $it->current());
assertType('int', $it->key());
$it->next();
assertType('string|null', $it->current());
assertType('int|null', $it->key());
}
if (!$it->valid()) {
assertType('null', $it->current());
assertType('null', $it->key());
$it->rewind();
assertType('string|null', $it->current());
assertType('int|null', $it->key());
}
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Done. Added two test functions to
PHPStan already handles this correctly — method calls on the iterator invalidate the |
VincentLanglet
left a comment
There was a problem hiding this comment.
-
restrict nsrt/bug-3674.php to PHP 8+ since mixed is used.
-
Benevolent union are lost by the returnTypeExtension
-
Ensure a class which override those function with a non null return type won't be considered as possibly returning null
public function current(): bool {
return false;
}
…eturn types, gate test on PHP 8+ - Skip adding null when the native return type explicitly excludes it (e.g. a class overriding current(): bool) - Preserve BenevolentUnionType when adding null so (int|string) becomes (int|string|null) instead of int|string|null - Add // lint >= 8.0 to bug-3674.php since mixed return type is used - Add test for non-null override (NonNullIterator) - Fix CustomIterator::key() assertion (native : int excludes null) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Pushed. Here's a summary of what was addressed:
All 12071 tests pass and |
| /** | ||
| * @phpstan-assert-if-true !null $this->current() | ||
| * @phpstan-assert-if-true !null $this->key() | ||
| */ | ||
| public function valid(): bool; |
There was a problem hiding this comment.
This is not true since null could be a valid current or key value (for instance if included in TKEY or TValue)
There was a problem hiding this comment.
Pushed. The reviewer was right — @phpstan-assert-if-true !null $this->current() on Iterator::valid() is incorrect when null is a valid TKey or TValue. For example, with Iterator<int, string|null>, current() can legitimately return null even when the iterator is valid, but the assertion would incorrectly narrow that away.
Removed the @phpstan-assert-if-true annotations from valid() in the stub and updated all test assertions that relied on the narrowing. The |null addition to return types (the core fix for #3674) is preserved. All 12071 tests pass and make phpstan reports no errors.
…alid TKey/TValue The assertion `@phpstan-assert-if-true !null $this->current()` is incorrect when TValue includes null (e.g. Iterator<int, string|null>): valid() returning true does not guarantee current() is non-null, since null is a legitimate value. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| assertType('string|null', $it->current()); | ||
| assertType('int|null', $it->key()); |
There was a problem hiding this comment.
Removing
/**
* @phpstan-assert-if-true !null $this->current()
* @phpstan-assert-if-true !null $this->key()
*/
public function valid(): bool;
introduce a regression since here current and key cannot be null since null is not in the TKey and TValue.
Is there a fix "in between" ?
There was a problem hiding this comment.
Pushed. Here's what was done to address the reviewer's concern:
Problem: Removing @phpstan-assert-if-true !null $this->current() from Iterator::valid() was correct (null CAN be a valid TKey/TValue), but it introduced a regression — for Iterator<int, string> where null is NOT in the template types, current() and key() stayed string|null and int|null even after valid() check.
Solution: Created IteratorValidMethodTypeSpecifyingExtension — a MethodTypeSpecifyingExtension for Iterator::valid() that, in the truthy context, narrows current() and key() to their base return types from the method reflection (i.e., the type before IteratorCurrentReturnTypeExtension adds null). This:
Iterator<int, string>:current()goes fromstring|nulltostringaftervalid()— the null added for invalid-state is removedIterator<int|null, string|null>:current()staysstring|nullaftervalid()— null is part of the original return type and is preservedCustomIterator<string>with@return T|null:current()staysstring|nullaftervalid()— the class explicitly includes null in its return typeNonNullIteratorwithcurrent(): string:current()staysstring— null was never addednext()andrewind()invalidate the narrowing, resetting back totype|null
All 12071 tests pass and make phpstan reports no errors.
…n on valid() Instead of @phpstan-assert-if-true !null (which incorrectly strips null when null is a valid TKey/TValue), use a MethodTypeSpecifyingExtension that narrows current()/key() to their base return types from the method reflection when valid() is truthy. This removes only the null added by IteratorCurrentReturnTypeExtension while preserving null when it is part of the declared return type or template parameter. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
When calling
Iterator::current()orIterator::key()directly (outside aforeachloop and without a priorvalid()check), the return type should includenullbecause these methods returnnullwhen the iterator is in an invalid state — e.g. when aGeneratoris exhausted, beforerewind(), or after iteration completes. Previously, PHPStan reported the return type asTValue/TKeyonly, leading to false positives like "Strict comparison using === between int and null will always evaluate to false."Changes
New
IteratorCurrentReturnTypeExtension(src/Type/Php/IteratorCurrentReturnTypeExtension.php): ADynamicMethodReturnTypeExtensionregistered for theIteratorclass that appends|nullto the return type ofcurrent()andkey()method calls. This applies to allIteratorimplementations (includingArrayIterator,Generator,FilterIteratorsubclasses, etc.).New
GeneratorSendReturnTypeExtension(src/Type/Php/GeneratorSendReturnTypeExtension.php): ADynamicMethodReturnTypeExtensionforGenerator::send(), which also returnsnullwhen the generator is exhausted.Iterator::valid()assert in stub (stubs/iterable.stub): Added@phpstan-assert-if-true !null $this->current()and@phpstan-assert-if-true !null $this->key()toIterator::valid(). This means:if ($it->valid()),current()andkey()are narrowed to excludenullwhile ($it->valid())loops, same narrowing applies!$it->valid()branch),current()andkey()are narrowed to exactlynullforeachis unaffected:foreachresolves value/key types viagetIterableValueType()/getIterableKeyType()onObjectType, which use Traversable template types directly — not thecurrent()/key()method return types. Soforeach ($it as $k => $v)continues to giveTValue/TKeywithoutnull.Updated existing tests:
tests/PHPStan/Analyser/nsrt/bug-7519.php: Updated assertions forFilterIteratorsubclasscurrent()/key()to include|nulltests/PHPStan/Rules/Methods/data/infer-array-key.php: UpdatedArrayIterator::key()assertions to include|nullAnalogous cases probed and covered in tests
Iterator::current()andIterator::key()on genericIterator<TKey, TValue>— fixedGenerator::current()andGenerator::key()— fixed (via Iterator extension)Generator::send()— fixed (via dedicated extension)ArrayIterator::current()andArrayIterator::key()— fixed (via Iterator extension)IteratorIterator::current()andIteratorIterator::key()— fixed (via Iterator extension)NoRewindIterator::current()andNoRewindIterator::key()— fixed (via Iterator extension)Iteratorimplementations — fixed (via Iterator extension)valid()narrowing inif,while, and negated!valid()— all testedforeach— verified unaffectedRoot cause
PHPStan's Iterator stubs declared
current()as returningTValueandkey()as returningTKey, but PHP'sIteratorinterface methods actually returnnullwhen the iterator is in an invalid state. The fix usesDynamicMethodReturnTypeExtension(rather than modifying stub return types) to avoid interference with the function signature map and template type resolution thatgetIterableValueType()/getIterableKeyType()rely on forforeachloops. The@phpstan-assert-if-trueonvalid()provides automatic narrowing.Test
tests/PHPStan/Analyser/nsrt/bug-3674.php: Comprehensive regression test covering:current()/key()without and withvalid()checkIterator<TKey, TValue>without and withvalid()checkArrayIteratorwithout and withvalid()checkGenerator::send()returnsTValue|nullwhile ($it->valid())loop narrowing!$it->valid()narrowing tonullIteratorimplementationIteratorIteratorandNoRewindIteratorforeachloops remainTValue/TKey(no null)Fixes phpstan/phpstan#3674