From 840a2bb482676b55302fcc02034abdc53fcbec66 Mon Sep 17 00:00:00 2001 From: Nicolas PHILIPPE Date: Sat, 18 Jul 2026 12:56:03 +0200 Subject: [PATCH] fix: narrow flatten() type --- bin/NarrowingGenerator.php | 8 +++++++ phpstan.neon | 9 +++++++ src/Collection.php | 4 +++- src/ImmutableCollection.php | 4 +++- src/List/ImmutableList.php | 4 +++- src/List/ListInterface.php | 4 +++- src/List/ListLogic.php | 4 ++-- src/List/WritableList.php | 4 +++- src/Set/ImmutableSet.php | 4 +++- src/Set/Set.php | 4 +++- src/Set/SetLogic.php | 4 ++-- src/Set/WritableSet.php | 4 +++- src/WritableCollection.php | 4 +++- tests/Type/FlattenTypeTest.php | 44 ++++++++++++++++++++++++++++++++++ 14 files changed, 92 insertions(+), 13 deletions(-) create mode 100644 tests/Type/FlattenTypeTest.php diff --git a/bin/NarrowingGenerator.php b/bin/NarrowingGenerator.php index 345ba9e..8aad7cc 100644 --- a/bin/NarrowingGenerator.php +++ b/bin/NarrowingGenerator.php @@ -203,6 +203,7 @@ public static function generateSetNarrowing(string $collectionContent, array $bl ': Collection' => ': Set', 'Collection' => 'Set', 'Collection<(E is null ? never : E)>' => 'Set<(E is null ? never : E)>', + 'Collection<(E is iterable ? value-of : E)>' => 'Set<(E is iterable ? value-of : E)>', 'Collection' => 'Set', 'Collection' => 'Set', 'Collection' => 'Set', @@ -233,6 +234,7 @@ public static function generateImmutableSetNarrowing(string $immutableCollection ': ImmutableCollection' => ': ImmutableSet', 'ImmutableCollection' => 'ImmutableSet', 'ImmutableCollection<(E is null ? never : E)>' => 'ImmutableSet<(E is null ? never : E)>', + 'ImmutableCollection<(E is iterable ? value-of : E)>' => 'ImmutableSet<(E is iterable ? value-of : E)>', 'ImmutableCollection' => 'ImmutableSet', 'ImmutableCollection' => 'ImmutableSet', 'ImmutableCollection' => 'ImmutableSet', @@ -288,6 +290,7 @@ public static function generateListNarrowing(string $collectionContent, array $b ': Collection' => ': ListInterface', 'Collection' => 'ListInterface', 'Collection<(E is null ? never : E)>' => 'ListInterface<(E is null ? never : E)>', + 'Collection<(E is iterable ? value-of : E)>' => 'ListInterface<(E is iterable ? value-of : E)>', 'Collection' => 'ListInterface', 'Collection' => 'ListInterface', 'Collection' => 'ListInterface', @@ -318,6 +321,7 @@ public static function generateImmutableCollectionNarrowing(string $collectionCo ': Collection' => ': ImmutableCollection', 'Collection' => 'ImmutableCollection', 'Collection<(E is null ? never : E)>' => 'ImmutableCollection<(E is null ? never : E)>', + 'Collection<(E is iterable ? value-of : E)>' => 'ImmutableCollection<(E is iterable ? value-of : E)>', 'Collection' => 'ImmutableCollection', 'Collection' => 'ImmutableCollection', 'Collection' => 'ImmutableCollection', @@ -345,6 +349,7 @@ public static function generateWritableCollectionTransformationNarrowing(string ': Collection' => ': ImmutableCollection', 'Collection' => 'ImmutableCollection', 'Collection<(E is null ? never : E)>' => 'ImmutableCollection<(E is null ? never : E)>', + 'Collection<(E is iterable ? value-of : E)>' => 'ImmutableCollection<(E is iterable ? value-of : E)>', 'Collection' => 'ImmutableCollection', 'Collection' => 'ImmutableCollection', 'Collection' => 'ImmutableCollection', @@ -372,6 +377,7 @@ public static function generateWritableListTransformationNarrowing(string $colle ': Collection' => ': ImmutableList', 'Collection' => 'ImmutableList', 'Collection<(E is null ? never : E)>' => 'ImmutableList<(E is null ? never : E)>', + 'Collection<(E is iterable ? value-of : E)>' => 'ImmutableList<(E is iterable ? value-of : E)>', 'Collection' => 'ImmutableList', 'Collection' => 'ImmutableList', 'Collection' => 'ImmutableList', @@ -399,6 +405,7 @@ public static function generateWritableSetTransformationNarrowing(string $collec ': Collection' => ': ImmutableSet', 'Collection' => 'ImmutableSet', 'Collection<(E is null ? never : E)>' => 'ImmutableSet<(E is null ? never : E)>', + 'Collection<(E is iterable ? value-of : E)>' => 'ImmutableSet<(E is iterable ? value-of : E)>', 'Collection' => 'ImmutableSet', 'Collection' => 'ImmutableSet', 'Collection' => 'ImmutableSet', @@ -450,6 +457,7 @@ public static function generateImmutableListNarrowing(string $immutableCollectio ': ImmutableCollection' => ': ImmutableList', 'ImmutableCollection' => 'ImmutableList', 'ImmutableCollection<(E is null ? never : E)>' => 'ImmutableList<(E is null ? never : E)>', + 'ImmutableCollection<(E is iterable ? value-of : E)>' => 'ImmutableList<(E is iterable ? value-of : E)>', 'ImmutableCollection' => 'ImmutableList', 'ImmutableCollection' => 'ImmutableList', 'ImmutableCollection' => 'ImmutableList', diff --git a/phpstan.neon b/phpstan.neon index b346fbe..7b95677 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -38,6 +38,15 @@ parameters: - src/Set/Set.php - src/Set/ImmutableSet.php - src/Set/WritableSet.php + # flatten()'s conditional return type cannot be evaluated in classes with a + # concrete E (the subject is already substituted), so the trait vs ancestor + # comparison sees structurally different but semantically equal conditionals. + - + message: '#::flatten\(\)#' + identifier: method.childReturnType + paths: + - src/List/ListLogic.php + - src/Set/SetLogic.php # classes which implement ImmutableMap. The trait's put()/putFirst() # accepts string|int|bool|float|object but the store expects string. - diff --git a/src/Collection.php b/src/Collection.php index 216bb3f..73da2c1 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -409,8 +409,10 @@ public function flatMap(Closure $transform): Collection; /** * Flatten a collection of iterables into a single collection. + * Iterable elements are flattened one level; non-iterable elements are kept as-is. + * The array{} in value-of keeps the type resolvable when E is never (empty collections). * - * @return Collection + * @return Collection<(E is iterable ? value-of : E)> */ #[NoDiscard] public function flatten(): Collection; diff --git a/src/ImmutableCollection.php b/src/ImmutableCollection.php index 26ffeff..22bca07 100644 --- a/src/ImmutableCollection.php +++ b/src/ImmutableCollection.php @@ -192,8 +192,10 @@ public function flatMap(Closure $transform): ImmutableCollection; /** * Flatten a collection of iterables into a single collection. + * Iterable elements are flattened one level; non-iterable elements are kept as-is. + * The array{} in value-of keeps the type resolvable when E is never (empty collections). * - * @return ImmutableCollection + * @return ImmutableCollection<(E is iterable ? value-of : E)> */ #[NoDiscard] public function flatten(): ImmutableCollection; diff --git a/src/List/ImmutableList.php b/src/List/ImmutableList.php index b1be945..ac478bf 100644 --- a/src/List/ImmutableList.php +++ b/src/List/ImmutableList.php @@ -252,8 +252,10 @@ public function flatMap(Closure $transform): ImmutableList; /** * Flatten a collection of iterables into a single collection. + * Iterable elements are flattened one level; non-iterable elements are kept as-is. + * The array{} in value-of keeps the type resolvable when E is never (empty collections). * - * @return ImmutableList + * @return ImmutableList<(E is iterable ? value-of : E)> */ #[NoDiscard] public function flatten(): ImmutableList; diff --git a/src/List/ListInterface.php b/src/List/ListInterface.php index e02a18e..f274602 100644 --- a/src/List/ListInterface.php +++ b/src/List/ListInterface.php @@ -170,8 +170,10 @@ public function flatMap(Closure $transform): ListInterface; /** * Flatten a collection of iterables into a single collection. + * Iterable elements are flattened one level; non-iterable elements are kept as-is. + * The array{} in value-of keeps the type resolvable when E is never (empty collections). * - * @return ListInterface + * @return ListInterface<(E is iterable ? value-of : E)> */ #[NoDiscard] public function flatten(): ListInterface; diff --git a/src/List/ListLogic.php b/src/List/ListLogic.php index 2e939e9..db5082c 100644 --- a/src/List/ListLogic.php +++ b/src/List/ListLogic.php @@ -239,9 +239,9 @@ public function flatMap(Closure $transform): ImmutableList /** * {@inheritDoc} * - * @return ImmutableList + * @return ImmutableList<(E is iterable ? value-of : E)> */ - #[NoDiscard] + #[NoDiscard] // @phpstan-ignore conditionalType.subjectNotFound, return.unresolvableType (in classes with a concrete E the conditional subject is already substituted and stays unevaluated) public function flatten(): ImmutableList { return $this->newCollectionOf(new FlattenOperation($this->store)->items()); diff --git a/src/List/WritableList.php b/src/List/WritableList.php index 29bc540..f0d53fd 100644 --- a/src/List/WritableList.php +++ b/src/List/WritableList.php @@ -227,8 +227,10 @@ public function flatMap(Closure $transform): ImmutableList; /** * Flatten a collection of iterables into a single collection. + * Iterable elements are flattened one level; non-iterable elements are kept as-is. + * The array{} in value-of keeps the type resolvable when E is never (empty collections). * - * @return ImmutableList + * @return ImmutableList<(E is iterable ? value-of : E)> */ #[NoDiscard] public function flatten(): ImmutableList; diff --git a/src/Set/ImmutableSet.php b/src/Set/ImmutableSet.php index 8b84250..81c592c 100644 --- a/src/Set/ImmutableSet.php +++ b/src/Set/ImmutableSet.php @@ -202,8 +202,10 @@ public function flatMap(Closure $transform): ImmutableSet; /** * Flatten a collection of iterables into a single collection. + * Iterable elements are flattened one level; non-iterable elements are kept as-is. + * The array{} in value-of keeps the type resolvable when E is never (empty collections). * - * @return ImmutableSet + * @return ImmutableSet<(E is iterable ? value-of : E)> */ #[NoDiscard] public function flatten(): ImmutableSet; diff --git a/src/Set/Set.php b/src/Set/Set.php index 9328610..95539e7 100644 --- a/src/Set/Set.php +++ b/src/Set/Set.php @@ -86,8 +86,10 @@ public function flatMap(Closure $transform): Set; /** * Flatten a collection of iterables into a single collection. + * Iterable elements are flattened one level; non-iterable elements are kept as-is. + * The array{} in value-of keeps the type resolvable when E is never (empty collections). * - * @return Set + * @return Set<(E is iterable ? value-of : E)> */ #[NoDiscard] public function flatten(): Set; diff --git a/src/Set/SetLogic.php b/src/Set/SetLogic.php index 041409e..5ca1e26 100644 --- a/src/Set/SetLogic.php +++ b/src/Set/SetLogic.php @@ -118,9 +118,9 @@ public function flatMap(Closure $transform): ImmutableSet /** * {@inheritDoc} * - * @return ImmutableSet + * @return ImmutableSet<(E is iterable ? value-of : E)> */ - #[NoDiscard] + #[NoDiscard] // @phpstan-ignore conditionalType.subjectNotFound, return.unresolvableType (in classes with a concrete E the conditional subject is already substituted and stays unevaluated) public function flatten(): ImmutableSet { return $this->newCollectionOf(new FlattenOperation($this->store)->items()); diff --git a/src/Set/WritableSet.php b/src/Set/WritableSet.php index 1000827..78b36cb 100644 --- a/src/Set/WritableSet.php +++ b/src/Set/WritableSet.php @@ -185,8 +185,10 @@ public function flatMap(Closure $transform): ImmutableSet; /** * Flatten a collection of iterables into a single collection. + * Iterable elements are flattened one level; non-iterable elements are kept as-is. + * The array{} in value-of keeps the type resolvable when E is never (empty collections). * - * @return ImmutableSet + * @return ImmutableSet<(E is iterable ? value-of : E)> */ #[NoDiscard] public function flatten(): ImmutableSet; diff --git a/src/WritableCollection.php b/src/WritableCollection.php index b6ce9fc..4c43610 100644 --- a/src/WritableCollection.php +++ b/src/WritableCollection.php @@ -185,8 +185,10 @@ public function flatMap(Closure $transform): ImmutableCollection; /** * Flatten a collection of iterables into a single collection. + * Iterable elements are flattened one level; non-iterable elements are kept as-is. + * The array{} in value-of keeps the type resolvable when E is never (empty collections). * - * @return ImmutableCollection + * @return ImmutableCollection<(E is iterable ? value-of : E)> */ #[NoDiscard] public function flatten(): ImmutableCollection; diff --git a/tests/Type/FlattenTypeTest.php b/tests/Type/FlattenTypeTest.php new file mode 100644 index 0000000..7efb211 --- /dev/null +++ b/tests/Type/FlattenTypeTest.php @@ -0,0 +1,44 @@ +', listOf([listOf([1, 2]), listOf([3])])->flatten()); +assertType('Noctud\Collection\Set\ImmutableSet', setOf([listOf([1, 2]), listOf([3])])->flatten()); +assertType('Noctud\Collection\List\ImmutableList', mutableListOf([listOf([1, 2])])->flatten()); + +// Only one level is flattened. +assertType( + 'Noctud\Collection\List\ImmutableList>', + listOf([listOf([listOf([1])])])->flatten(), +); + +// Plain arrays are iterables too. +/** @var Collection> $arrays */ +$arrays = listOf([[1, 2], [3]]); +assertType('Noctud\Collection\Collection', $arrays->flatten()); + +// Non-iterable elements are kept as-is... +assertType('Noctud\Collection\List\ImmutableList', listOf(['a', 'b'])->flatten()); + +// ...and the conditional distributes over union element types. +/** @var Collection|string> $mixed */ +$mixed = listOf([[1], 'a']); +assertType('Noctud\Collection\Collection', $mixed->flatten()); + +// flatten() on an empty collection stays typed (E = never). +assertType('Noctud\Collection\List\ImmutableList<*NEVER*>', listOf([])->flatten());