type accessors for static analysis

This commit is contained in:
Kacper Donat 2026-10-27 22:44:36 +01:00
parent b20ef22cfd
commit 61f934ffd7
12 changed files with 140 additions and 120 deletions

View File

@ -207,7 +207,7 @@ third to 2. To prevent that, two kinds of callables are called with the value on
- **PHP's internal functions**, written as first-class callables: `trim(...)`, `round(...)`, `strtoupper(...)`.
This also holds when they are wrapped, e.g. `f\partial(str_pad(...), _, 3, '0')`.
- **Closures declaring exactly one parameter**, like `fn ($value) => ...`.
- **Any callable declaring exactly one parameter**, like `fn ($value) => ...` or a one-parameter method.
```php
[1.25, 2.5, 3.14159] |> c\map(round(...)); // [1.0, 3.0, 3.0], not [1.0, 2.5, 3.14]
@ -321,8 +321,10 @@ $byId->get('d4')->destination; // 'Oliwa'
### Queries
`first`, `last`, `find`, `contains`, `any`, `some`, `every`, `count`, `isEmpty`, `min`, `max`, `reduce`, `sum` and
`join` turn a collection into a single value. They stop reading as soon as the answer is known, which matters for
generators.
`join` turn a collection into a single value. `first`, `find`, `any`, `some`, `every`, `contains` and `isEmpty`
stop reading as soon as the answer is known, which matters for generators. The others read everything, with two
shortcuts: `last` reads arrays and php-ds collections from the end, and `count` asks countable containers for their
size.
```php
$departures |> c\every(fn (Departure $departure) => $departure->delay < 600); // true
@ -392,8 +394,8 @@ not copied), and results whose type says whether they are a list (`Seq`), a mapp
## Static analysis
Every function has `@template` generics, so PHPStan (level 8 in this repository) follows element types through a
chain, `|>` included:
Every function in `Collections` has `@template` generics, so PHPStan (level 8 in this repository) follows element
types through a chain, `|>` included:
```php
/** @param Ds\Seq<Departure> $departures */
@ -407,10 +409,17 @@ function delayedLines(Ds\Seq $departures): ?string
```
Eager transforms with a callback keep the container in the type as well (a `Ds\Map` in, a `Ds\Map` out; a
generator in, `Ds\Seq|list` out). The limits of what can be inferred:
generator in, `Ds\Seq|list` out). A callable PHPStan knows nothing about never causes an error, it only makes
the result `mixed`. The limits of what can be inferred:
- **Element types come from the callback's parameter and return types.** `fn ($departure) => ...` without types
gives `mixed`.
- **Accessors and predicates are not generic.** `t\property()`, `t\field()`, `t\method()` and `t\getter()` are typed
`\Closure(object): mixed`, and the `p\` builders return `Predicate`. Chains using them analyse cleanly, but the
elements become `object` (`c\sortBy(t\property('delay'))`) or `mixed` (`c\map(t\property('line'))`,
`c\max(t\property('delay'))`). Where the type matters, write the closure:
`c\map(fn (Departure $departure): string => $departure->line)`.
- **`sum()` doesn't check that the values are numbers** when called without a selector.
- **Keys are inferred only when the callback declares a `$key` parameter.** Otherwise the keys of the result are
`mixed` (`int|string` for arrays).
- **Transforms without a callback** (`values`, `keys`, `unique`, `sort()`, `slice`, `take`, `skip`, `prepend`,
@ -424,7 +433,7 @@ generator in, `Ds\Seq|list` out). The limits of what can be inferred:
```bash
composer test # both PHPUnit suites
composer phpstan # PHPStan, level 8, on src/ and the type tests in tests/types/
composer phpstan # PHPStan level 8: src/ as PHP 8.2 (phpstan.neon), tests/types/ as PHP 8.5 (phpstan.types.neon)
```
- `composer test` runs the main suite and the no-php-ds suite (`phpunit.no-ds.xml`), which hides the polyfill to
@ -432,6 +441,6 @@ composer phpstan # PHPStan, level 8, on src/ and the type tests in tests/types/
be hidden: it prints a notice and passes. Run it on a PHP without ext-ds to cover those paths.
- `tests/PipeSyntaxTest.php` is the only test that uses `|>`. It is loaded only on PHP 8.5 and newer (see
`phpunit.xml`), so the rest of the suite still runs on PHP 8.2. Keep `|>` out of other tests.
- `tests/types/` holds PHPStan type assertions (`assertType()`). They are analysed, never executed. Add one when
you change a signature.
- `tests/types/` holds PHPStan type assertions (`assertType()`). They are analysed, never executed, with their own
config, because they use `|>`. Add one when you change a signature.
- `phpstan-baseline.neon` lists errors in code that predates PHPStan in this project. New code must not add to it.

View File

@ -41,6 +41,9 @@
"phpunit",
"phpunit -c phpunit.no-ds.xml"
],
"phpstan": "phpstan analyse"
"phpstan": [
"phpstan analyse",
"phpstan analyse -c phpstan.types.neon"
]
}
}

View File

@ -168,12 +168,6 @@ parameters:
count: 1
path: src/Predicates/objects.php
-
message: '#^Function Kadet\\Functional\\Predicates\\instance\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Predicates/objects.php
-
message: '#^Function Kadet\\Functional\\Predicates\\method\(\) has parameter \$args with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
@ -270,36 +264,18 @@ parameters:
count: 1
path: src/Reflection/ReflectionPartiallyAppliedFunction.php
-
message: '#^Function Kadet\\Functional\\Transforms\\field\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Transforms/objects.php
-
message: '#^Function Kadet\\Functional\\Transforms\\field\(\) has parameter \$field with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Transforms/objects.php
-
message: '#^Function Kadet\\Functional\\Transforms\\getter\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Transforms/objects.php
-
message: '#^Function Kadet\\Functional\\Transforms\\getter\(\) has parameter \$property with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Transforms/objects.php
-
message: '#^Function Kadet\\Functional\\Transforms\\method\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Transforms/objects.php
-
message: '#^Function Kadet\\Functional\\Transforms\\method\(\) has parameter \$args with no type specified\.$#'
identifier: missingType.parameter
@ -312,12 +288,6 @@ parameters:
count: 1
path: src/Transforms/objects.php
-
message: '#^Function Kadet\\Functional\\Transforms\\property\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Transforms/objects.php
-
message: '#^Function Kadet\\Functional\\Transforms\\property\(\) has parameter \$property with no type specified\.$#'
identifier: missingType.parameter

View File

@ -1,12 +1,10 @@
parameters:
level: 8
# tests/types checks inference through the pipe operator, which needs PHP 8.5 to parse. PHPStan parses with the
# lowest configured version, so this can't be a range starting at the library's PHP 8.2 floor; that floor is
# covered by running the test suite on PHP 8.2.
phpVersion: 80500
# The library's floor. The type tests use the 8.5 pipe operator, so they have their own config:
# phpstan.types.neon. `composer phpstan` runs both.
phpVersion: 80200
paths:
- src
- tests/types
ignoreErrors:
-
# Eager transforms are typed with a conditional closure type that maps the input container to the result

7
phpstan.types.neon Normal file
View File

@ -0,0 +1,7 @@
# Type tests (tests/types): assertType() checks of what PHPStan infers for library users, including through the pipe
# operator, which needs PHP 8.5 to parse. The library itself is analysed against PHP 8.2 by phpstan.neon.
parameters:
level: 8
phpVersion: 80500
paths:
- tests/types

View File

@ -30,9 +30,9 @@ use function Kadet\Functional\Collections\Internal\unpairs;
/**
* Transforms each value with `$fn($value, $key)`, keeping the keys.
*
* @template TKey
* @template TValue
* @template TResult
* @template TKey = mixed
* @template TValue = mixed
* @template TResult = mixed
*
* @param callable(TValue, TKey): TResult $fn
*
@ -52,8 +52,8 @@ function map(callable $fn): \Closure
/**
* Keeps the values for which `$fn($value, $key)` is truthy, or the truthy values when `$fn` is null.
*
* @template TKey
* @template TValue
* @template TKey = mixed
* @template TValue = mixed
*
* @param (callable(TValue, TKey): mixed)|null $fn
*
@ -75,8 +75,8 @@ function filter(?callable $fn = null): \Closure
/**
* Drops the values for which `$fn($value, $key)` is truthy. The inverse of {@see filter()}.
*
* @template TKey
* @template TValue
* @template TKey = mixed
* @template TValue = mixed
*
* @param callable(TValue, TKey): mixed $fn
*
@ -98,9 +98,9 @@ function reject(callable $fn): \Closure
/**
* Maps each value to an iterable with `$fn($value, $key)` and flattens the results one level, with sequential keys.
*
* @template TKey
* @template TValue
* @template TResult
* @template TKey = mixed
* @template TValue = mixed
* @template TResult = mixed
*
* @param callable(TValue, TKey): iterable<mixed, TResult> $fn
*
@ -170,8 +170,8 @@ function unique(): \Closure
* Removes values whose `$selector($value, $key)` result was already seen, keeping the first occurrence.
* Selected values are compared strictly, objects by identity.
*
* @template TKey
* @template TValue
* @template TKey = mixed
* @template TValue = mixed
*
* @param callable(TValue, TKey): mixed $selector
*
@ -260,7 +260,7 @@ function skip(int $n): \Closure
/**
* Puts `$values` before the values of the input, with sequential keys.
*
* @template TValue
* @template TValue = mixed
*
* @param TValue ...$values
*
@ -282,7 +282,7 @@ function prepend(mixed ...$values): \Closure
/**
* Puts `$values` after the values of the input, with sequential keys.
*
* @template TValue
* @template TValue = mixed
*
* @param TValue ...$values
*
@ -296,7 +296,7 @@ function append(mixed ...$values): \Closure
/**
* Puts the values of `$other` after the values of the input, with sequential keys.
*
* @template TValue
* @template TValue = mixed
*
* @param iterable<mixed, TValue> $other
*
@ -320,8 +320,8 @@ function concat(iterable $other): \Closure
*
* The return value of `$fn` is ignored.
*
* @template TKey
* @template TValue
* @template TKey = mixed
* @template TValue = mixed
*
* @param callable(TValue, TKey): mixed $fn
*
@ -343,8 +343,8 @@ function each(callable $fn): \Closure
/**
* Alias of {@see each()}.
*
* @template TKey
* @template TValue
* @template TKey = mixed
* @template TValue = mixed
*
* @param callable(TValue, TKey): mixed $fn
*

View File

@ -23,9 +23,9 @@ use function Kadet\Functional\Collections\Internal\dsAvailable;
/**
* Indexes the values by `$selector($value, $key)` into a `Ds\Map`. The last value wins on a duplicate key.
*
* @template TKey
* @template TValue
* @template TIndex
* @template TKey = mixed
* @template TValue = mixed
* @template TIndex = mixed
*
* @param callable(TValue, TKey): TIndex $selector
*
@ -50,9 +50,9 @@ function keyBy(callable $selector): \Closure
* Groups the values by `$selector($value, $key)` into a `Ds\Map` of `Ds\Seq`. Groups and their values keep the
* order in which they were encountered.
*
* @template TKey
* @template TValue
* @template TGroup
* @template TKey = mixed
* @template TValue = mixed
* @template TGroup = mixed
*
* @param callable(TValue, TKey): TGroup $selector
*

View File

@ -32,8 +32,8 @@ use function Kadet\Functional\compile;
*
* Returns null when nothing matches; with `$required` it throws instead, so a found `null` stays distinguishable.
*
* @template TKey
* @template TValue
* @template TKey = mixed
* @template TValue = mixed
*
* @param (callable(TValue, TKey): mixed)|null $predicate
*
@ -54,8 +54,8 @@ function first(?callable $predicate = null, bool $required = false): \Closure
* Arrays and php-ds containers are read from the end, other iterables are read to the end. Returns null when
* nothing matches; with `$required` it throws instead.
*
* @template TKey
* @template TValue
* @template TKey = mixed
* @template TValue = mixed
*
* @param (callable(TValue, TKey): mixed)|null $predicate
*
@ -105,8 +105,8 @@ function last(?callable $predicate = null, bool $required = false): \Closure
/**
* Alias of {@see first()} with a mandatory predicate: returns the first value matching `$predicate($value, $key)`.
*
* @template TKey
* @template TValue
* @template TKey = mixed
* @template TValue = mixed
*
* @param callable(TValue, TKey): mixed $predicate
*
@ -142,8 +142,8 @@ function contains(mixed $value): \Closure
/**
* Whether any value satisfies `$predicate($value, $key)`. False for an empty collection.
*
* @template TKey
* @template TValue
* @template TKey = mixed
* @template TValue = mixed
*
* @param callable(TValue, TKey): mixed $predicate
*
@ -167,8 +167,8 @@ function any(callable $predicate): \Closure
/**
* Alias of {@see any()}.
*
* @template TKey
* @template TValue
* @template TKey = mixed
* @template TValue = mixed
*
* @param callable(TValue, TKey): mixed $predicate
*
@ -182,8 +182,8 @@ function some(callable $predicate): \Closure
/**
* Whether every value satisfies `$predicate($value, $key)`. True for an empty collection.
*
* @template TKey
* @template TValue
* @template TKey = mixed
* @template TValue = mixed
*
* @param callable(TValue, TKey): mixed $predicate
*
@ -250,9 +250,9 @@ function isEmpty(): \Closure
*
* With a selector this returns the selected value, not the element it was selected from.
*
* @template TKey
* @template TValue
* @template TSelected
* @template TKey = mixed
* @template TValue = mixed
* @template TSelected = mixed
*
* @param (callable(TValue, TKey): TSelected)|null $selector
*
@ -270,9 +270,9 @@ function min(?callable $selector = null): \Closure
*
* With a selector this returns the selected value, not the element it was selected from.
*
* @template TKey
* @template TValue
* @template TSelected
* @template TKey = mixed
* @template TValue = mixed
* @template TSelected = mixed
*
* @param (callable(TValue, TKey): TSelected)|null $selector
*
@ -288,9 +288,9 @@ function max(?callable $selector = null): \Closure
/**
* Folds the collection into a single value with `$fn($carry, $value, $key)`, starting from `$initial`.
*
* @template TKey
* @template TValue
* @template TCarry
* @template TKey = mixed
* @template TValue = mixed
* @template TCarry = mixed
*
* @param callable(TCarry, TValue, TKey): TCarry $fn
* @param TCarry $initial
@ -315,14 +315,12 @@ function reduce(callable $fn, mixed $initial = null): \Closure
/**
* Sums the values, or `$selector($value, $key)` when given. An empty collection sums to 0.
*
* @template TKey
* @template TValue
* @template TKey = mixed
* @template TValue = mixed
*
* @param (callable(TValue, TKey): (int|float))|null $selector
*
* @return ($selector is null
* ? \Closure(iterable<mixed, int|float>): (int|float)
* : \Closure(iterable<TKey, TValue>): (int|float))
* @return \Closure(iterable<TKey, TValue>): (int|float)
*/
function sum(?callable $selector = null): \Closure
{

View File

@ -34,9 +34,9 @@ use function Kadet\Functional\compile;
*
* For `Ds\Set` this follows `Ds\Set::map`, so equal results collapse into one.
*
* @template TKey
* @template TValue
* @template TResult
* @template TKey = mixed
* @template TValue = mixed
* @template TResult = mixed
*
* @param callable(TValue, TKey): TResult $fn
*
@ -60,8 +60,8 @@ function map(callable $fn): \Closure
*
* Keys are preserved for arrays and `Ds\Map`; `Ds\Seq` is reindexed.
*
* @template TKey
* @template TValue
* @template TKey = mixed
* @template TValue = mixed
*
* @param (callable(TValue, TKey): mixed)|null $fn
*
@ -83,8 +83,8 @@ function filter(?callable $fn = null): \Closure
/**
* Drops the values for which `$fn($value, $key)` is truthy. The inverse of {@see filter()}.
*
* @template TKey
* @template TValue
* @template TKey = mixed
* @template TValue = mixed
*
* @param callable(TValue, TKey): mixed $fn
*
@ -108,9 +108,9 @@ function reject(callable $fn): \Closure
*
* Arrays give a list array, `Ds\Set` gives `Ds\Set`, anything else gives `Ds\Seq`.
*
* @template TKey
* @template TValue
* @template TResult
* @template TKey = mixed
* @template TValue = mixed
* @template TResult = mixed
*
* @param callable(TValue, TKey): iterable<mixed, TResult> $fn
*
@ -169,8 +169,8 @@ function unique(): \Closure
* Removes values whose `$selector($value, $key)` result was already seen, keeping the first occurrence.
* Selected values are compared strictly, objects by identity.
*
* @template TKey
* @template TValue
* @template TKey = mixed
* @template TValue = mixed
*
* @param callable(TValue, TKey): mixed $selector
*
@ -194,7 +194,7 @@ function uniqueBy(callable $selector): \Closure
*
* The sort is stable and keeps the keys of arrays and `Ds\Map`. The input is never modified.
*
* @template TValue
* @template TValue = mixed
*
* @param (callable(TValue, TValue): int)|null $comparator
*
@ -219,8 +219,8 @@ function sort(?callable $comparator = null): \Closure
* The sort is stable (equal values keep their order in both directions) and keeps the keys of arrays and
* `Ds\Map`. The selector is called once per value. The input is never modified.
*
* @template TKey
* @template TValue
* @template TKey = mixed
* @template TValue = mixed
*
* @param callable(TValue, TKey): mixed $selector
*
@ -291,7 +291,7 @@ function skip(int $n): \Closure
/**
* Puts `$values` before the values of the input, as a list (keys are dropped).
*
* @template TValue
* @template TValue = mixed
*
* @param TValue ...$values
*
@ -307,7 +307,7 @@ function prepend(mixed ...$values): \Closure
/**
* Puts `$values` after the values of the input, as a list (keys are dropped).
*
* @template TValue
* @template TValue = mixed
*
* @param TValue ...$values
*
@ -321,7 +321,7 @@ function append(mixed ...$values): \Closure
/**
* Puts the values of `$other` after the values of the input, as a list (keys are dropped).
*
* @template TValue
* @template TValue = mixed
*
* @param iterable<mixed, TValue> $other
*
@ -339,8 +339,8 @@ function concat(iterable $other): \Closure
*
* Arrays and `Ds\Map` are merged by key; for `Ds\Seq`, `Ds\Set` and other iterables the values are appended.
*
* @template TKey
* @template TValue
* @template TKey = mixed
* @template TValue = mixed
*
* @param iterable<TKey, TValue> $other
*
@ -360,8 +360,8 @@ function merge(iterable $other): \Closure
* The return value of `$fn` is ignored. Arrays and php-ds collections are returned as they are; any other
* iterable is consumed and its values are returned as a `Ds\Seq` (or a list array without php-ds).
*
* @template TKey
* @template TValue
* @template TKey = mixed
* @template TValue = mixed
*
* @param callable(TValue, TKey): mixed $fn
*
@ -393,8 +393,8 @@ function each(callable $fn): \Closure
/**
* Alias of {@see each()}.
*
* @template TKey
* @template TValue
* @template TKey = mixed
* @template TValue = mixed
*
* @param callable(TValue, TKey): mixed $fn
*

View File

@ -49,6 +49,9 @@ function field(string $field, $predicate)
return predicate(f\pipe(t\field($field), $predicate));
}
/**
* @return \Kadet\Functional\Predicate
*/
function instance(string $class)
{
return new ClosurePredicate(function ($value) use ($class) {

View File

@ -9,11 +9,17 @@
namespace Kadet\Functional\Transforms;
/**
* @return \Closure(object): mixed
*/
function getter($property)
{
return method('get'.ucfirst($property));
}
/**
* @return \Closure(object): mixed
*/
function property($property)
{
return function ($object) use ($property) {
@ -25,6 +31,9 @@ function property($property)
};
}
/**
* @return \Closure(object): mixed
*/
function field($field)
{
return function ($object) use ($field) {
@ -32,6 +41,9 @@ function field($field)
};
}
/**
* @return \Closure(object): mixed
*/
function method($method, ...$args)
{
return function ($object) use ($method, $args) {

View File

@ -17,6 +17,8 @@
use Kadet\Functional\Collections as c;
use Kadet\Functional\Collections\Lazy as lazy;
use Kadet\Functional\Predicates as p;
use Kadet\Functional\Transforms as t;
use function PHPStan\Testing\assertType;
@ -101,6 +103,24 @@ function queries(\Ds\Seq $foos, \Ds\Map $map, array $ints): void
assertType('int', c\reduce(fn (int $carry, Foo $foo): int => $carry + $foo->id, 0)($foos));
}
/**
* Accessors (`t\`) and predicates (`p\`) are untyped about the object they read, so the element type is `object` or
* `mixed`; what matters is that these idioms analyse without errors.
*
* @param list<Foo> $list
*/
function accessors(array $list): void
{
assertType('array<int|string, mixed>', c\map(t\property('name'))($list));
assertType('array<int|string, object>', c\sortBy(t\property('id'))($list));
assertType('object|null', $list |> c\sortBy(t\property('id'), descending: true) |> c\first());
assertType('float|int', c\sum(t\property('id'))($list));
assertType('mixed', c\max(t\property('id'))($list));
assertType('Ds\\Map<mixed, object>', c\keyBy(t\property('id'))($list));
assertType('array<int|string, mixed>', c\filter(p\property('id', p\gt(1)))($list));
assertType('mixed', $list |> c\first(p\property('name', p\same('a'))));
}
/**
* The pipe operator: PHPStan infers through `|>` like through a call.
*