From e05891732948e59f29d643d38b64876cacb7b6ec Mon Sep 17 00:00:00 2001 From: William Duyck Date: Thu, 26 Mar 2026 19:08:19 +0000 Subject: [PATCH] Improve PHPStan type hints Tighten type information across the helper utilities so key and value types are preserved more accurately through pipelines when analysed with PHPStan. Add type assertions for the affected helpers and move the most advanced higher-order signatures into a PHPStan stub file so the runtime code can stay small, predictable, and function-first. # Conflicts: # src/pipe.php --- phpstan.neon | 3 + src/pipe.php | 106 +++++++++++++++++------------- stubs/pipe.stub | 62 +++++++++++++++++ tests/ArrayNthTest.php | 5 +- tests/IterableFirstTest.php | 1 + tests/phpstan-type-assertions.php | 60 +++++++++++++++++ 6 files changed, 191 insertions(+), 46 deletions(-) create mode 100644 phpstan.neon create mode 100644 stubs/pipe.stub create mode 100644 tests/phpstan-type-assertions.php diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..69af7a0 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,3 @@ +parameters: + stubFiles: + - stubs/pipe.stub diff --git a/src/pipe.php b/src/pipe.php index f7a0828..bb5fc65 100644 --- a/src/pipe.php +++ b/src/pipe.php @@ -46,8 +46,9 @@ function apply(callable $callback): Closure /** * Return unary callable for array_all * - * @param callable $callback - * @return Closure(array) : bool + * @template TValue + * @param callable(TValue, array-key): bool $callback + * @return Closure(array): bool */ function array_all(callable $callback): Closure { @@ -59,8 +60,9 @@ function array_all(callable $callback): Closure /** * Return unary callable for array_any * - * @param callable $callback - * @return Closure(array) : bool + * @template TValue + * @param callable(TValue, array-key): bool $callback + * @return Closure(array): bool */ function array_any(callable $callback): Closure { @@ -107,7 +109,7 @@ function array_dissoc(string|int ...$keys): Closure * * @param callable $callback * @param int $mode - * @return Closure(array) : array + * @return Closure(array): array */ function array_filter(callable $callback, int $mode = 0): Closure { @@ -136,8 +138,10 @@ function array_flatten(array $array): array /** * Return unary callable for array_map * - * @param callable $callback - * @return Closure(array) : array + * @template TValue + * @template TResult + * @param callable(TValue): TResult $callback + * @return Closure(array): array */ function array_map(callable $callback): Closure { @@ -230,9 +234,6 @@ function array_nth(int $i): Closure /** * Return unary callable for array_reduce * - * The reducer is called as: $callback($carry, $value) (no array key is provided). - * For an empty array, the result is $initial (or null if omitted). - * * @param callable $callback * @param mixed $initial * @return Closure(array): (mixed|null) @@ -298,8 +299,8 @@ function array_slice(int $offset, ?int $length = null, bool $preserve_keys = fal * Equivalent to: * fn(array $xs) => array_reduce($xs, fn($sum, $x) => $sum + $callback($x), 0) * - * @param callable $callback - * @return Closure + * @param callable $callback Callback that returns a numeric value (int|float) + * @return Closure(array): (int|float) */ function array_sum(callable $callback): Closure { @@ -398,7 +399,7 @@ function array_unique(int $flags = \SORT_STRING): Closure } return function (array $array) use ($flags): array { - /** @phpstan-ignore-next-line */ + /** @var array $array */ return \array_unique($array, $flags); }; } @@ -438,7 +439,7 @@ function explode(string $separator, int $limit = PHP_INT_MAX): Closure * Returns a predicate: $x === $value * * @param mixed $value - * @return Closure(mixed) : bool + * @return Closure(mixed): bool */ function equals(mixed $value): Closure { @@ -473,7 +474,7 @@ function if_else(callable $predicate, callable $then, callable $else): Closure function implode(string $separator = ""): Closure { return function (array $array) use ($separator): string { - /** @phpstan-ignore-next-line */ + /** @var array $array */ return \implode($separator, $array); }; } @@ -496,7 +497,7 @@ function increment(int|float $by = 1): Closure * Short-circuits: stops iterating as soon as false (or actually !== true) is found. * * @param callable|null $callback - * @return Closure(iterable) : bool + * @return Closure(iterable): bool */ function iterable_all(?callable $callback = null): Closure { @@ -527,7 +528,7 @@ function iterable_all(?callable $callback = null): Closure * Short-circuits: stops iterating as soon as a match is found. * * @param callable|null $callback - * @return Closure(iterable) : bool + * @return Closure(iterable): bool */ function iterable_any(?callable $callback = null): Closure { @@ -564,7 +565,7 @@ function iterable_any(?callable $callback = null): Closure * @param bool $preserve_keys * @return Closure(iterable): Generator> * @throws InvalidArgumentException when $size < 1 -*/ + */ function iterable_chunk(int $size, bool $preserve_keys = false): Closure { if ($size < 1) { @@ -602,13 +603,15 @@ function iterable_chunk(int $size, bool $preserve_keys = false): Closure /** * Return unary callable for filtering over an iterable * - * @param callable $callback - * @return Closure(iterable) : Generator + * @template TValue + * @param callable(TValue, array-key): bool $callback + * @return Closure(iterable): Generator */ function iterable_filter(callable $callback): Closure { return function (iterable $iterable) use ($callback): Generator { foreach ($iterable as $key => $value) { + /** @var array-key $key */ if ($callback($value, $key)) { yield $key => $value; } @@ -621,8 +624,9 @@ function iterable_filter(callable $callback): Closure * * Warning: for Generators/Iterators, this consumes one element. * - * @param iterable $iterable - * @return mixed + * @template TValue + * @param iterable $iterable + * @return TValue|null */ function iterable_first(iterable $iterable): mixed { @@ -658,13 +662,16 @@ function iterable_flatten(bool $preserve_keys = true): Closure /** * Return unary callable for mapping over an iterable * - * @param callable $callback - * @return Closure(iterable) : Generator + * @template TValue + * @template TResult + * @param callable(TValue): TResult $callback + * @return Closure(iterable): Generator */ function iterable_map(callable $callback): Closure { return function (iterable $iterable) use ($callback): Generator { foreach ($iterable as $key => $value) { + /** @var array-key $key */ yield $key => $callback($value); } }; @@ -672,10 +679,10 @@ function iterable_map(callable $callback): Closure /** * Return unary callable that returns the nth element (0-based) from an iterable. - * Returns 0 if n is out of bounds + * Returns null if n is out of bounds * * @param int $n 0-based index - * @return Closure(iterable) : (mixed|null) + * @return Closure(iterable): (mixed|null) * @throws InvalidArgumentException if $n < 0 */ function iterable_nth(int $n): \Closure @@ -704,12 +711,13 @@ function iterable_nth(int $n): \Closure * * @param callable $callback * @param mixed $initial - * @return Closure(iterable) : mixed + * @return Closure(iterable): mixed */ function iterable_reduce(callable $callback, mixed $initial = null): Closure { return function (iterable $iterable) use ($callback, $initial): mixed { $carry = $initial; + /** @var array-key $key */ foreach ($iterable as $key => $value) { $carry = $callback($carry, $value, $key); } @@ -733,13 +741,14 @@ function iterable_reduce(callable $callback, mixed $initial = null): Closure * @param callable $callback * @param callable $until * @param mixed $initial - * @return Closure(iterable): array{0:mixed, 1:mixed, 2:mixed} + * @return Closure(iterable): array{0:mixed, 1:array-key|null, 2:mixed|null} */ function iterable_reduce_until(callable $callback, callable $until, mixed $initial = null): Closure { return function (iterable $iterable) use ($callback, $until, $initial): array { $carry = $initial; + /** @var array-key $key */ foreach ($iterable as $key => $value) { $carry = $callback($carry, $value, $key); @@ -802,7 +811,6 @@ function iterable_string(int $size = 1): Closure } - /** * Return unary callable for taking $count items from an iterable * @@ -981,14 +989,12 @@ function iterate(callable $callback, bool $include_seed = true): Closure } /** - * Return unary callable for preg_replace + * Return unary callable for preg_match * - * @template TFlags of 0|256|512|768 - * @template TFlags of int * @param string $pattern - * @param TFlags $flags + * @param int-mask<0, 256, 512> $flags Bitmask of `PREG_OFFSET_CAPTURE` (256), `PREG_UNMATCHED_AS_NULL` (512) * @param int $offset - * @return Closure(string): array}|string|null> + * @return Closure(string): array}> */ function preg_match(string $pattern, int $flags = 0, int $offset = 0): Closure { @@ -999,11 +1005,12 @@ function preg_match(string $pattern, int $flags = 0, int $offset = 0): Closure } /** - * @template TFlags of int + * Return unary callable for preg_match_all + * * @param string $pattern - * @param TFlags $flags + * @param int-mask<0, 1, 2, 256, 512> $flags Combination of `PREG_PATTERN_ORDER` (1), `PREG_SET_ORDER` (2), `PREG_OFFSET_CAPTURE` (256), `PREG_UNMATCHED_AS_NULL` (512) * @param int $offset - * @return Closure(string) : array + * @return Closure(string): array */ function preg_match_all(string $pattern, int $flags = 0, int $offset = 0): Closure { @@ -1018,10 +1025,13 @@ function preg_match_all(string $pattern, int $flags = 0, int $offset = 0): Closu * Return unary callable for preg_replace * $count is ignored * + * When the subject is a `string`, the return is `string|null`. + * When the subject is an `array`, the return is `array|null`. + * * @param string|array $pattern * @param string|array $replacement * @param int $limit - * @return Closure(array|string) : (array|string|null) + * @return Closure>(TSubject): (TSubject|null) */ function preg_replace(string|array $pattern, string|array $replacement, int $limit = -1): Closure { @@ -1040,7 +1050,7 @@ function preg_replace(string|array $pattern, string|array $replacement, int $lim function rsort(int $flags = SORT_REGULAR): Closure { return function (array $array) use ($flags): array { - /** @phpstan-ignore-next-line */ + /** @var array $array */ \rsort($array, $flags); return $array; }; @@ -1055,7 +1065,7 @@ function rsort(int $flags = SORT_REGULAR): Closure function sort(int $flags = SORT_REGULAR): Closure { return function (array $array) use ($flags): array { - /** @phpstan-ignore-next-line */ + /** @var array $array */ \sort($array, $flags); return $array; }; @@ -1063,9 +1073,13 @@ function sort(int $flags = SORT_REGULAR): Closure /** * Return unary callable for str_replace + * + * When the subject is a `string`, the return is a string. + * When the subject is an `array` of `strings`, the return is an `array` of `strings`. + * * @param string|array $search * @param string|array $replace - * @return Closure(array|string): (string|array) + * @return Closure>(TSubject): TSubject */ function str_replace(string|array $search, string|array $replace): Closure { @@ -1130,8 +1144,9 @@ function unless(callable $predicate, callable $callback): Closure * Return unary callable for usort * Reindexes keys * - * @param callable $callback - * @return Closure(array): list + * @template TValue + * @param callable(TValue, TValue): int $callback + * @return Closure(array): list */ function usort(callable $callback): Closure { @@ -1145,8 +1160,9 @@ function usort(callable $callback): Closure * Return unary callable for uasort * Preserves keys * - * @param callable(mixed, mixed): int $callback - * @return Closure(array): array + * @template TValue + * @param callable(TValue, TValue): int $callback + * @return Closure(array): array */ function uasort(callable $callback): Closure { diff --git a/stubs/pipe.stub b/stubs/pipe.stub new file mode 100644 index 0000000..f66ee32 --- /dev/null +++ b/stubs/pipe.stub @@ -0,0 +1,62 @@ +(array): array + */ +function array_map(callable $callback): Closure +{ +} + +/** + * @template TKey of array-key = array-key + * @template TValue = mixed + * @param iterable $iterable + * @return array + */ +function collect(iterable $iterable): array +{ +} + +/** + * @template TValue = mixed + * @template TKey of array-key = array-key + * @param callable(TValue, TKey): bool $callback + * @return Closure(iterable): Generator + */ +function iterable_filter(callable $callback): Closure +{ +} + +/** + * @template TValue + * @template TResult + * @param callable(TValue): TResult $callback + * @return Closure(iterable): Generator + */ +function iterable_map(callable $callback): Closure +{ +} + +/** + * @return Closure(array): (TValue|null) + */ +function array_nth(int $i): Closure +{ +} + +/** + * @return Closure(iterable): (TValue|null) + */ +function iterable_nth(int $n): Closure +{ +} diff --git a/tests/ArrayNthTest.php b/tests/ArrayNthTest.php index 0ae0296..fb2cac6 100644 --- a/tests/ArrayNthTest.php +++ b/tests/ArrayNthTest.php @@ -59,7 +59,10 @@ public function test_returns_null_when_index_is_out_of_bounds(): void public function test_returns_null_for_empty_array(): void { - $result = [] + /** @var array $stage */ + $stage = []; + + $result = $stage |> array_nth(0); self::assertNull($result); diff --git a/tests/IterableFirstTest.php b/tests/IterableFirstTest.php index 66e3599..25cb93e 100644 --- a/tests/IterableFirstTest.php +++ b/tests/IterableFirstTest.php @@ -36,6 +36,7 @@ public function test_ignores_keys_and_returns_first_value(): void public function test_returns_null_for_empty_iterable(): void { + /** @var array $stage */ $stage = []; $result = iterable_first($stage); diff --git a/tests/phpstan-type-assertions.php b/tests/phpstan-type-assertions.php new file mode 100644 index 0000000..114a3e6 --- /dev/null +++ b/tests/phpstan-type-assertions.php @@ -0,0 +1,60 @@ + $a */ +$a = ['hello']; + +assertType('string', $s |> p\str_replace('a', 'b')); +assertType('array', $a |> p\str_replace('a', 'b')); + +// ── preg_replace: generic TSubject|null preserved ─────────────────── + +assertType('string|null', $s |> p\preg_replace('/x/', 'y')); +assertType('array|null', $a |> p\preg_replace('/x/', 'y')); + +// ── array_map: generic TValue → TResult ───────────────────────────── + +/** @var array $intMap */ +$intMap = ['a' => 1, 'b' => 2]; + +assertType('array', $intMap |> p\array_map(fn(int $n): int => $n * 2)); + +// ── iterable_map: generic TValue → TResult ────────────────────────── + +/** @var iterable $strings */ +$strings = [10 => 'a', 20 => 'bb']; + +assertType('Generator, mixed, mixed>', $strings |> p\iterable_map(fn(string $v): int => strlen($v))); + +// ── iterable_filter: preserves generic TValue ─────────────────────── + +assertType('Generator', $strings |> p\iterable_filter(fn(string $v, int $k): bool => $k === 10 || $v !== '')); + +// ── collect ───────────────────────────────────────────────────────── + +/** @var \Generator $gen */ +assertType('array', p\collect($gen)); + +// ── array_nth / iterable_nth: expose nullability ──────────────────── + +assertType('int|null', $intMap |> p\array_nth(0)); +assertType('string|null', $strings |> p\iterable_nth(1));