Skip to content

Commit

Permalink
[3.x] Add template annotations
Browse files Browse the repository at this point in the history
Adds template annotations turning the `PromiseInterface` into a generic.

Variables `$p1` and `$p2` in the following code example both are `PromiseInterface<int|string>`.

```php
$f = function (): int|string {
    return time() % 2 ? 'string' : time();
};

/**
 * @return PromiseInterface<int|string>
 */
$fp = function (): PromiseInterface {
    return resolve(time() % 2 ? 'string' : time());
};

$p1 = resolve($f());
$p2 = $fp();
```

When calling `then` on `$p1` or `$p2`, PHPStan understand that function `$f1` is type hinting its parameter fine, but `$f2` will throw during runtime:

```php
$p2->then(static function (int|string $a) {});
$p2->then(static function (bool $a) {});
```

Builds on top of #246 and #188 and is a requirement for reactphp/async#40
  • Loading branch information
simPod authored and WyriHaximus committed Jul 4, 2023
1 parent d66fa66 commit 6772ffb
Show file tree
Hide file tree
Showing 31 changed files with 313 additions and 94 deletions.
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/.gitattributes export-ignore
/.github/ export-ignore
/.gitignore export-ignore
/phpstan.legacy.neon.dist export-ignore
/phpstan.neon.dist export-ignore
/phpunit.xml.dist export-ignore
/phpunit.xml.legacy export-ignore
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ jobs:
name: PHPStan (PHP ${{ matrix.php }})
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
php:
- 8.2
Expand All @@ -52,3 +53,6 @@ jobs:
coverage: none
- run: composer install
- run: vendor/bin/phpstan
if: ${{ matrix.php >= 7.2 }}
- run: vendor/bin/phpstan --configuration="phpstan.legacy.neon.dist"
if: ${{ matrix.php < 7.2 }}
7 changes: 7 additions & 0 deletions phpstan.legacy.neon.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
parameters:
ignoreErrors:
- '#Template type T is declared as covariant, but occurs in contravariant position in parameter result of method React\\Promise\\Promise::settle\(\).#'
- '#Template type T is declared as covariant, but occurs in contravariant position in parameter promise of method React\\Promise\\Promise::unwrap\(\).#'

includes:
- phpstan.neon.dist
12 changes: 10 additions & 2 deletions src/Deferred.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@

namespace React\Promise;

/**
* @template T
*/
final class Deferred
{
/** @var Promise */
/**
* @var PromiseInterface<T>
*/
private $promise;

/** @var callable */
Expand All @@ -21,13 +26,16 @@ public function __construct(callable $canceller = null)
}, $canceller);
}

/**
* @return PromiseInterface<T>
*/
public function promise(): PromiseInterface
{
return $this->promise;
}

/**
* @param mixed $value
* @param T $value
*/
public function resolve($value): void
{
Expand Down
13 changes: 8 additions & 5 deletions src/Internal/FulfilledPromise.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,17 @@

/**
* @internal
*
* @template-implements PromiseInterface<T>
* @template-covariant T
*/
final class FulfilledPromise implements PromiseInterface
{
/** @var mixed */
/** @var T */
private $value;

/**
* @param mixed $value
* @param T $value
* @throws \InvalidArgumentException
*/
public function __construct($value = null)
Expand Down Expand Up @@ -47,9 +50,9 @@ public function catch(callable $onRejected): PromiseInterface
public function finally(callable $onFulfilledOrRejected): PromiseInterface
{
return $this->then(function ($value) use ($onFulfilledOrRejected): PromiseInterface {
return resolve($onFulfilledOrRejected())->then(function () use ($value) {
return $value;
});
$onFulfilledOrRejected();

return resolve($value);
});
}

Expand Down
9 changes: 6 additions & 3 deletions src/Internal/RejectedPromise.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@

/**
* @internal
*
* @template-implements PromiseInterface<never>
* @template-covariant T
*/
final class RejectedPromise implements PromiseInterface
{
Expand Down Expand Up @@ -47,9 +50,9 @@ public function catch(callable $onRejected): PromiseInterface
public function finally(callable $onFulfilledOrRejected): PromiseInterface
{
return $this->then(null, function (\Throwable $reason) use ($onFulfilledOrRejected): PromiseInterface {
return resolve($onFulfilledOrRejected())->then(function () use ($reason): PromiseInterface {
return new RejectedPromise($reason);
});
$onFulfilledOrRejected();

return new RejectedPromise($reason);
});
}

Expand Down
28 changes: 20 additions & 8 deletions src/Promise.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@

use React\Promise\Internal\RejectedPromise;

/**
* @template-implements PromiseInterface<T>
* @template-covariant T
*/
final class Promise implements PromiseInterface
{
/** @var ?callable */
private $canceller;

/** @var ?PromiseInterface */
/** @var ?PromiseInterface<T> */
private $result;

/** @var callable[] */
Expand Down Expand Up @@ -77,13 +81,13 @@ public function catch(callable $onRejected): PromiseInterface
public function finally(callable $onFulfilledOrRejected): PromiseInterface
{
return $this->then(static function ($value) use ($onFulfilledOrRejected) {
return resolve($onFulfilledOrRejected())->then(function () use ($value) {
return $value;
});
$onFulfilledOrRejected();

return $value;
}, static function ($reason) use ($onFulfilledOrRejected) {
return resolve($onFulfilledOrRejected())->then(function () use ($reason) {
return new RejectedPromise($reason);
});
$onFulfilledOrRejected();

return new RejectedPromise($reason);
});
}

Expand Down Expand Up @@ -166,6 +170,10 @@ private function reject(\Throwable $reason): void
$this->settle(reject($reason));
}

/**
* Test out if null can be promise
* @param PromiseInterface<T>|PromiseInterface<never> $result
*/
private function settle(PromiseInterface $result): void
{
$result = $this->unwrap($result);
Expand Down Expand Up @@ -193,13 +201,17 @@ private function settle(PromiseInterface $result): void
}
}

/**
* @param PromiseInterface<T>|PromiseInterface<never> $promise
* @return PromiseInterface<T>
*/
private function unwrap(PromiseInterface $promise): PromiseInterface
{
while ($promise instanceof self && null !== $promise->result) {
$promise = $promise->result;
}

return $promise;
return $promise; /** @phpstan-ignore-line */
}

private function call(callable $cb): void
Expand Down
20 changes: 11 additions & 9 deletions src/PromiseInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

namespace React\Promise;

/**
* @template-covariant T
*/
interface PromiseInterface
{
/**
Expand All @@ -28,9 +31,9 @@ interface PromiseInterface
* 2. `$onFulfilled` and `$onRejected` will never be called more
* than once.
*
* @param callable|null $onFulfilled
* @param callable|null $onRejected
* @return PromiseInterface
* @template TFulfilled as PromiseInterface<T>|T|void
* @param (callable(T): TFulfilled)|null $onFulfilled
* @return PromiseInterface<T>
*/
public function then(?callable $onFulfilled = null, ?callable $onRejected = null): PromiseInterface;

Expand All @@ -44,8 +47,7 @@ public function then(?callable $onFulfilled = null, ?callable $onRejected = null
* Additionally, you can type hint the `$reason` argument of `$onRejected` to catch
* only specific errors.
*
* @param callable $onRejected
* @return PromiseInterface
* @return PromiseInterface<T>
*/
public function catch(callable $onRejected): PromiseInterface;

Expand Down Expand Up @@ -91,8 +93,8 @@ public function catch(callable $onRejected): PromiseInterface;
* ->finally('cleanup');
* ```
*
* @param callable $onFulfilledOrRejected
* @return PromiseInterface
* @param callable(): void $onFulfilledOrRejected
* @return PromiseInterface<T>
*/
public function finally(callable $onFulfilledOrRejected): PromiseInterface;

Expand All @@ -118,7 +120,7 @@ public function cancel(): void;
* ```
*
* @param callable $onRejected
* @return PromiseInterface
* @return PromiseInterface<T>
* @deprecated 3.0.0 Use catch() instead
* @see self::catch()
*/
Expand All @@ -135,7 +137,7 @@ public function otherwise(callable $onRejected): PromiseInterface;
* ```
*
* @param callable $onFulfilledOrRejected
* @return PromiseInterface
* @return PromiseInterface<T>
* @deprecated 3.0.0 Use finally() instead
* @see self::finally()
*/
Expand Down
24 changes: 14 additions & 10 deletions src/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@
*
* If `$promiseOrValue` is a promise, it will be returned as is.
*
* @param mixed $promiseOrValue
* @return PromiseInterface
* @template T
* @param PromiseInterface<T>|T $promiseOrValue
* @return PromiseInterface<T>
*/
function resolve($promiseOrValue): PromiseInterface
{
Expand All @@ -30,6 +31,7 @@ function resolve($promiseOrValue): PromiseInterface
$canceller = null;

if (\method_exists($promiseOrValue, 'cancel')) {
/** @var callable $canceller */
$canceller = [$promiseOrValue, 'cancel'];
}

Expand All @@ -54,8 +56,7 @@ function resolve($promiseOrValue): PromiseInterface
* throwing an exception. For example, it allows you to propagate a rejection with
* the value of another promise.
*
* @param \Throwable $reason
* @return PromiseInterface
* @return PromiseInterface<never>
*/
function reject(\Throwable $reason): PromiseInterface
{
Expand All @@ -68,8 +69,9 @@ function reject(\Throwable $reason): PromiseInterface
* will be an array containing the resolution values of each of the items in
* `$promisesOrValues`.
*
* @param iterable<mixed> $promisesOrValues
* @return PromiseInterface
* @template T
* @param iterable<PromiseInterface<T>|T> $promisesOrValues
* @return PromiseInterface<array<T>>
*/
function all(iterable $promisesOrValues): PromiseInterface
{
Expand Down Expand Up @@ -119,8 +121,9 @@ function (\Throwable $reason) use (&$continue, $reject): void {
* The returned promise will become **infinitely pending** if `$promisesOrValues`
* contains 0 items.
*
* @param iterable<mixed> $promisesOrValues
* @return PromiseInterface
* @template T
* @param iterable<PromiseInterface<T>|T> $promisesOrValues
* @return PromiseInterface<T>
*/
function race(iterable $promisesOrValues): PromiseInterface
{
Expand Down Expand Up @@ -154,8 +157,9 @@ function race(iterable $promisesOrValues): PromiseInterface
* The returned promise will also reject with a `React\Promise\Exception\LengthException`
* if `$promisesOrValues` contains 0 items.
*
* @param iterable<mixed> $promisesOrValues
* @return PromiseInterface
* @template T
* @param iterable<PromiseInterface<T>|T> $promisesOrValues
* @return PromiseInterface<T>
*/
function any(iterable $promisesOrValues): PromiseInterface
{
Expand Down
21 changes: 7 additions & 14 deletions tests/FunctionAllTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ class FunctionAllTest extends TestCase
public function shouldResolveEmptyInput(): void
{
$mock = $this->createCallableMock();
$mock
->expects(self::once())
$mock->expects(self::once())
->method('__invoke')
->with(self::identicalTo([]));

Expand All @@ -23,8 +22,7 @@ public function shouldResolveEmptyInput(): void
public function shouldResolveValuesArray(): void
{
$mock = $this->createCallableMock();
$mock
->expects(self::once())
$mock->expects(self::once())
->method('__invoke')
->with(self::identicalTo([1, 2, 3]));

Expand All @@ -36,8 +34,7 @@ public function shouldResolveValuesArray(): void
public function shouldResolvePromisesArray(): void
{
$mock = $this->createCallableMock();
$mock
->expects(self::once())
$mock->expects(self::once())
->method('__invoke')
->with(self::identicalTo([1, 2, 3]));

Expand All @@ -49,8 +46,7 @@ public function shouldResolvePromisesArray(): void
public function shouldResolveSparseArrayInput(): void
{
$mock = $this->createCallableMock();
$mock
->expects(self::once())
$mock->expects(self::once())
->method('__invoke')
->with(self::identicalTo([null, 1, null, 1, 1]));

Expand All @@ -62,8 +58,7 @@ public function shouldResolveSparseArrayInput(): void
public function shouldResolveValuesGenerator(): void
{
$mock = $this->createCallableMock();
$mock
->expects(self::once())
$mock->expects(self::once())
->method('__invoke')
->with(self::identicalTo([1, 2, 3]));

Expand All @@ -80,8 +75,7 @@ public function shouldResolveValuesGenerator(): void
public function shouldResolveValuesGeneratorEmpty(): void
{
$mock = $this->createCallableMock();
$mock
->expects(self::once())
$mock->expects(self::once())
->method('__invoke')
->with(self::identicalTo([]));

Expand Down Expand Up @@ -132,8 +126,7 @@ public function shouldRejectInfiteGeneratorOrRejectedPromises(): void
public function shouldPreserveTheOrderOfArrayWhenResolvingAsyncPromises(): void
{
$mock = $this->createCallableMock();
$mock
->expects(self::once())
$mock->expects(self::once())
->method('__invoke')
->with(self::identicalTo([1, 2, 3]));

Expand Down
Loading

0 comments on commit 6772ffb

Please sign in to comment.