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 reactphp#246 and
reactphp#188 and is a requirement for
reactphp/async#40
  • Loading branch information
WyriHaximus authored and clue committed Jul 11, 2023
1 parent d87b562 commit 1a00512
Show file tree
Hide file tree
Showing 27 changed files with 356 additions and 52 deletions.
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ jobs:
- 7.4
- 7.3
- 7.2
- 7.1
steps:
- uses: actions/checkout@v3
- uses: shivammathur/setup-php@v2
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"php": ">=7.1.0"
},
"require-dev": {
"phpstan/phpstan": "1.10.20 || 1.4.10",
"phpstan/phpstan": "1.10.20",
"phpunit/phpunit": "^9.5 || ^7.5"
},
"autoload": {
Expand Down
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
18 changes: 15 additions & 3 deletions src/Internal/FulfilledPromise.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,17 @@

/**
* @internal
*
* @template T
* @template-implements PromiseInterface<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 All @@ -26,14 +29,23 @@ public function __construct($value = null)
$this->value = $value;
}

/**
* @template TFulfilled
* @param ?(callable((T is void ? null : T)): (PromiseInterface<TFulfilled>|TFulfilled)) $onFulfilled
* @return PromiseInterface<($onFulfilled is null ? T : TFulfilled)>
*/
public function then(callable $onFulfilled = null, callable $onRejected = null): PromiseInterface
{
if (null === $onFulfilled) {
return $this;
}

try {
return resolve($onFulfilled($this->value));
/**
* @var PromiseInterface<T>|T $result
*/
$result = $onFulfilled($this->value);
return resolve($result);
} catch (\Throwable $exception) {
return new RejectedPromise($exception);
}
Expand Down
17 changes: 17 additions & 0 deletions src/Internal/RejectedPromise.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

/**
* @internal
*
* @template-implements PromiseInterface<never>
*/
final class RejectedPromise implements PromiseInterface
{
Expand Down Expand Up @@ -37,6 +39,12 @@ public function __destruct()
\error_log($message);
}

/**
* @template TRejected
* @param ?callable $onFulfilled
* @param ?(callable(\Throwable): (PromiseInterface<TRejected>|TRejected)) $onRejected
* @return PromiseInterface<($onRejected is null ? never : TRejected)>
*/
public function then(callable $onFulfilled = null, callable $onRejected = null): PromiseInterface
{
if (null === $onRejected) {
Expand All @@ -52,12 +60,21 @@ public function then(callable $onFulfilled = null, callable $onRejected = null):
}
}

/**
* @template TThrowable of \Throwable
* @template TRejected
* @param callable(TThrowable): (PromiseInterface<TRejected>|TRejected) $onRejected
* @return PromiseInterface<TRejected>
*/
public function catch(callable $onRejected): PromiseInterface
{
if (!_checkTypehint($onRejected, $this->reason)) {
return $this;
}

/**
* @var callable(\Throwable):(PromiseInterface<TRejected>|TRejected) $onRejected
*/
return $this->then(null, $onRejected);
}

Expand Down
23 changes: 22 additions & 1 deletion 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 T
* @template-implements PromiseInterface<T>
*/
final class Promise implements PromiseInterface
{
/** @var ?callable */
private $canceller;

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

/** @var callable[] */
Expand Down Expand Up @@ -66,13 +70,22 @@ static function () use (&$parent) {
);
}

/**
* @template TThrowable of \Throwable
* @template TRejected
* @param callable(TThrowable): (PromiseInterface<TRejected>|TRejected) $onRejected
* @return PromiseInterface<T|TRejected>
*/
public function catch(callable $onRejected): PromiseInterface
{
return $this->then(null, static function ($reason) use ($onRejected) {
if (!_checkTypehint($onRejected, $reason)) {
return new RejectedPromise($reason);
}

/**
* @var callable(\Throwable):(PromiseInterface<TRejected>|TRejected) $onRejected
*/
return $onRejected($reason);
});
}
Expand Down Expand Up @@ -175,6 +188,9 @@ private function reject(\Throwable $reason): void
$this->settle(reject($reason));
}

/**
* @param PromiseInterface<T> $result
*/
private function settle(PromiseInterface $result): void
{
$result = $this->unwrap($result);
Expand Down Expand Up @@ -207,9 +223,14 @@ private function settle(PromiseInterface $result): void
}
}

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

Expand Down
31 changes: 20 additions & 11 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,11 @@ 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
* @template TRejected
* @param ?(callable((T is void ? null : T)): (PromiseInterface<TFulfilled>|TFulfilled)) $onFulfilled
* @param ?(callable(\Throwable): (PromiseInterface<TRejected>|TRejected)) $onRejected
* @return PromiseInterface<($onRejected is null ? ($onFulfilled is null ? T : TFulfilled) : ($onFulfilled is null ? T|TRejected : TFulfilled|TRejected))>
*/
public function then(?callable $onFulfilled = null, ?callable $onRejected = null): PromiseInterface;

Expand All @@ -44,8 +49,10 @@ 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
* @template TThrowable of \Throwable
* @template TRejected
* @param callable(TThrowable): (PromiseInterface<TRejected>|TRejected) $onRejected
* @return PromiseInterface<T|TRejected>
*/
public function catch(callable $onRejected): PromiseInterface;

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

Expand All @@ -117,8 +124,10 @@ public function cancel(): void;
* $promise->catch($onRejected);
* ```
*
* @param callable $onRejected
* @return PromiseInterface
* @template TThrowable of \Throwable
* @template TRejected
* @param callable(TThrowable): (PromiseInterface<TRejected>|TRejected) $onRejected
* @return PromiseInterface<T|TRejected>
* @deprecated 3.0.0 Use catch() instead
* @see self::catch()
*/
Expand All @@ -134,8 +143,8 @@ public function otherwise(callable $onRejected): PromiseInterface;
* $promise->finally($onFulfilledOrRejected);
* ```
*
* @param callable $onFulfilledOrRejected
* @return PromiseInterface
* @param callable(): (void|PromiseInterface<void>) $onFulfilledOrRejected
* @return PromiseInterface<T>
* @deprecated 3.0.0 Use finally() instead
* @see self::finally()
*/
Expand Down
26 changes: 15 additions & 11 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 @@ -31,6 +32,7 @@ function resolve($promiseOrValue): PromiseInterface

if (\method_exists($promiseOrValue, 'cancel')) {
$canceller = [$promiseOrValue, 'cancel'];
assert(\is_callable($canceller));
}

return new Promise(function ($resolve, $reject) use ($promiseOrValue): void {
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,14 +121,15 @@ 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
{
$cancellationQueue = new Internal\CancellationQueue();

return new Promise(function ($resolve, $reject) use ($promisesOrValues, $cancellationQueue): void {
return new Promise(function (callable $resolve, callable $reject) use ($promisesOrValues, $cancellationQueue): void {
$continue = true;

foreach ($promisesOrValues as $promiseOrValue) {
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
8 changes: 7 additions & 1 deletion tests/DeferredTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@

use React\Promise\PromiseAdapter\CallbackPromiseAdapter;

/**
* @template T
*/
class DeferredTest extends TestCase
{
use PromiseTest\FullTestTrait;

/**
* @return CallbackPromiseAdapter<T>
*/
public function getPromiseTestAdapter(callable $canceller = null): CallbackPromiseAdapter
{
$d = new Deferred($canceller);
Expand Down Expand Up @@ -54,7 +60,7 @@ public function shouldRejectWithoutCreatingGarbageCyclesIfCancellerHoldsReferenc
gc_collect_cycles();
gc_collect_cycles(); // clear twice to avoid leftovers in PHP 7.4 with ext-xdebug and code coverage turned on

/** @var Deferred $deferred */
/** @var Deferred<never> $deferred */
$deferred = new Deferred(function () use (&$deferred) {
assert($deferred instanceof Deferred);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use function React\Promise\reject;

require __DIR__ . '/../vendor/autoload.php';

reject(new RuntimeException('foo'))->then(null, function (UnexpectedValueException $unexpected): void {
reject(new RuntimeException('foo'))->then(null, function (UnexpectedValueException $unexpected): void { // @phpstan-ignore-line
echo 'This will never be shown because the types do not match' . PHP_EOL;
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use function React\Promise\reject;

require __DIR__ . '/../vendor/autoload.php';

reject(new RuntimeException('foo'))->then(null, function (UnexpectedValueException $unexpected): void {
reject(new RuntimeException('foo'))->then(null, function (UnexpectedValueException $unexpected): void { // @phpstan-ignore-line
echo 'This will never be shown because the types do not match' . PHP_EOL;
});

Expand Down
Loading

0 comments on commit 1a00512

Please sign in to comment.