Skip to content

Add null to return type of Iterator::current(), Iterator::key(), and Generator::send() with valid() narrowing#5668

Closed
phpstan-bot wants to merge 5 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-gx7krzz
Closed

Add null to return type of Iterator::current(), Iterator::key(), and Generator::send() with valid() narrowing#5668
phpstan-bot wants to merge 5 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-gx7krzz

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When calling Iterator::current() or Iterator::key() directly (outside a foreach loop and without a prior valid() check), the return type should include null because these methods return null when the iterator is in an invalid state — e.g. when a Generator is exhausted, before rewind(), or after iteration completes. Previously, PHPStan reported the return type as TValue/TKey only, 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): A DynamicMethodReturnTypeExtension registered for the Iterator class that appends |null to the return type of current() and key() method calls. This applies to all Iterator implementations (including ArrayIterator, Generator, FilterIterator subclasses, etc.).

  • New GeneratorSendReturnTypeExtension (src/Type/Php/GeneratorSendReturnTypeExtension.php): A DynamicMethodReturnTypeExtension for Generator::send(), which also returns null when 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() to Iterator::valid(). This means:

    • After if ($it->valid()), current() and key() are narrowed to exclude null
    • Inside while ($it->valid()) loops, same narrowing applies
    • After the loop exits (or in !$it->valid() branch), current() and key() are narrowed to exactly null
  • foreach is unaffected: foreach resolves value/key types via getIterableValueType()/getIterableKeyType() on ObjectType, which use Traversable template types directly — not the current()/key() method return types. So foreach ($it as $k => $v) continues to give TValue/TKey without null.

  • Updated existing tests:

    • tests/PHPStan/Analyser/nsrt/bug-7519.php: Updated assertions for FilterIterator subclass current()/key() to include |null
    • tests/PHPStan/Rules/Methods/data/infer-array-key.php: Updated ArrayIterator::key() assertions to include |null

Analogous cases probed and covered in tests

  • Iterator::current() and Iterator::key() on generic Iterator<TKey, TValue> — fixed
  • Generator::current() and Generator::key() — fixed (via Iterator extension)
  • Generator::send() — fixed (via dedicated extension)
  • ArrayIterator::current() and ArrayIterator::key() — fixed (via Iterator extension)
  • IteratorIterator::current() and IteratorIterator::key() — fixed (via Iterator extension)
  • NoRewindIterator::current() and NoRewindIterator::key() — fixed (via Iterator extension)
  • Custom Iterator implementations — fixed (via Iterator extension)
  • valid() narrowing in if, while, and negated !valid() — all tested
  • foreach — verified unaffected

Root cause

PHPStan's Iterator stubs declared current() as returning TValue and key() as returning TKey, but PHP's Iterator interface methods actually return null when the iterator is in an invalid state. The fix uses DynamicMethodReturnTypeExtension (rather than modifying stub return types) to avoid interference with the function signature map and template type resolution that getIterableValueType()/getIterableKeyType() rely on for foreach loops. The @phpstan-assert-if-true on valid() provides automatic narrowing.

Test

  • tests/PHPStan/Analyser/nsrt/bug-3674.php: Comprehensive regression test covering:
    • Generator current()/key() without and with valid() check
    • Generic Iterator<TKey, TValue> without and with valid() check
    • ArrayIterator without and with valid() check
    • Generator::send() returns TValue|null
    • while ($it->valid()) loop narrowing
    • Negated !$it->valid() narrowing to null
    • Custom Iterator implementation
    • IteratorIterator and NoRewindIterator
    • foreach loops remain TValue/TKey (no null)

Fixes phpstan/phpstan#3674

…, 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
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

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>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. Added two test functions to tests/PHPStan/Analyser/nsrt/bug-3674.php that verify the reviewer's request:

  1. testNextResetsNarrowing: After $it->valid() narrows types, calling $it->next() resets current() back to string|null and key() back to int|null.
  2. testRewindResetsNarrowing: After !$it->valid() narrows types to null, calling $it->rewind() resets current() back to string|null and key() back to int|null.

PHPStan already handles this correctly — method calls on the iterator invalidate the @phpstan-assert-if-true narrowing from valid(). All tests pass and make phpstan reports no errors.

@VincentLanglet VincentLanglet self-assigned this May 15, 2026
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

  • 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>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's a summary of what was addressed:

  1. PHP 8+ gate: Added // lint >= 8.0 to bug-3674.php since the CustomIterator class uses mixed return type.

  2. Benevolent union preservation: Both extensions now check if the original return type was a BenevolentUnionType and re-wrap the result via TypeUtils::toBenevolentUnion() after adding null. This ensures (int|string) becomes (int|string|null) instead of losing the benevolent wrapping to plain int|string|null.

  3. Non-null native return type: Both extensions now check ExtendedParametersAcceptor::getNativeReturnType() — if the native type explicitly excludes null (e.g., a class declares current(): bool), null is not added. This respects concrete class contracts. Added NonNullIterator test class and fixed the CustomIterator::key() assertion (native : int excludes null).

All 12071 tests pass and make phpstan reports no errors.

Comment thread stubs/iterable.stub Outdated
Comment on lines +46 to +50
/**
* @phpstan-assert-if-true !null $this->current()
* @phpstan-assert-if-true !null $this->key()
*/
public function valid(): bool;
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.

This is not true since null could be a valid current or key value (for instance if included in TKEY or TValue)

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.

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>
Comment on lines +26 to +27
assertType('string|null', $it->current());
assertType('int|null', $it->key());
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.

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" ?

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.

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 from string|null to string after valid() — the null added for invalid-state is removed
  • Iterator<int|null, string|null>: current() stays string|null after valid() — null is part of the original return type and is preserved
  • CustomIterator<string> with @return T|null: current() stays string|null after valid() — the class explicitly includes null in its return type
  • NonNullIterator with current(): string: current() stays string — null was never added
  • next() and rewind() invalidate the narrowing, resetting back to type|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>
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.

2 participants