diff --git a/README.md b/README.md index f73a3fb..5f38f44 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,30 @@ $result = (new Ok(2)) echo $result->unwrapErr(); // "Too small" ``` +Each step in a chain may fail with a **different error type**. The error types +are composed into a union, so PHPStan tracks every error the chain can produce: + +```php +final class ValidationError {} +final class NotFoundError {} + +/** @return Result */ +function validateUserId(string $raw): Result +{ + return ctype_digit($raw) ? new Ok((int) $raw) : new Err(new ValidationError()); +} + +/** @return Result */ +function findUserNameById(int $id): Result +{ + return $id === 42 ? new Ok('Alice') : new Err(new NotFoundError()); +} + +// PHPStan infers Result +$userName = validateUserId('42')->andThen(findUserNameById(...)); +echo $userName->unwrap(); // "Alice" +``` + ### Combining Results ```php @@ -114,6 +138,35 @@ $result = (new Err("oops")) ->inspectErr(fn($e) => error_log("Error occurred: $e")); ``` +## Type Safety + +This library is designed to be used with [PHPStan](https://phpstan.org/) at level max +and leans on several of its generics features: + +- **Sealed interface** — `Result` is annotated with `@phpstan-sealed Ok|Err`, so + PHPStan knows `Ok` and `Err` are the only implementations. A `match (true)` over + `instanceof` checks is recognized as exhaustive, and the `else` branch of an + `instanceof Ok` check narrows to `Err`. +- **Covariant type parameters** — `T` and `E` are declared `@template-covariant`, + so `Ok` (which is `Result`) and `Err` (which is `Result`) + are assignable to any `Result`. A function declared to return + `Result` can simply `return new Ok($user);`. +- **Error-type composition** — `andThen()`/`and()` widen the error channel to + `E|F` and `orElse()`/`or()` widen the success channel to `T|U`, so chains that + mix failure types stay precisely typed. (This deliberately diverges from Rust, + whose `and`/`or` family keeps the other channel's type fixed.) +- **Type narrowing** — `isOk()`/`isErr()` narrow `$result` to `Ok`/`Err` + via `@phpstan-assert-if-true`; `unwrap()`/`unwrapErr()` use conditional return + types (`never` on the impossible side), and `unwrapOr()`/`unwrapOrElse()` + resolve to `T` on `Ok` and to the default's type on `Err`. +- **Precise concrete receivers** — when the receiver is statically `Ok` or + `Err`, no-op methods keep their exact type (`$ok->orElse(...)` stays + `Ok`, `$err->andThen(...)` stays `Err`) instead of widening to a union. + +Note: because the templates are covariant, PHPStan preserves constant value types +(`new Ok(10)` is `Ok<10>`, not `Ok`). Type a variable or parameter as `int` +if you want the widened type. + ## API Reference ### Result Methods diff --git a/src/Err.php b/src/Err.php index e143ceb..968d589 100644 --- a/src/Err.php +++ b/src/Err.php @@ -9,7 +9,7 @@ /** * Err はエラー値を表します. * - * @template E + * @template-covariant E * * @implements Result */ @@ -86,24 +86,40 @@ public function unwrapOrElse(callable $fn): mixed return $fn($this->value); } + /** + * @return $this + */ #[Override] public function map(callable $fn): Result { return $this; } + /** + * @template F + * + * @param callable(E): F $fn + * + * @return Err + */ #[Override] public function mapErr(callable $fn): Result { return new self($fn($this->value)); } + /** + * @return $this + */ #[Override] public function inspect(callable $fn): Result { return $this; } + /** + * @return $this + */ #[Override] public function inspectErr(callable $fn): Result { @@ -112,24 +128,48 @@ public function inspectErr(callable $fn): Result return $this; } + /** + * @template U + * @template V + * + * @param U $default + * @param callable(never): V $fn + * + * @return U + */ #[Override] public function mapOr(mixed $default, callable $fn): mixed { return $default; } + /** + * @template U + * @template V + * + * @param callable(): U $default_fn + * @param callable(never): V $fn + * + * @return U + */ #[Override] public function mapOrElse(callable $default_fn, callable $fn): mixed { return $default_fn(); } + /** + * @return $this + */ #[Override] public function and(Result $res): Result { return $this; } + /** + * @return $this + */ #[Override] public function andThen(callable $fn): Result { diff --git a/src/Ok.php b/src/Ok.php index a5f93b2..fea4b7b 100644 --- a/src/Ok.php +++ b/src/Ok.php @@ -9,7 +9,7 @@ /** * Ok は成功値を表します. * - * @template T + * @template-covariant T * * @implements Result */ @@ -63,7 +63,8 @@ public function unwrapErr(): never } /** - * @param T $default + * @template U + * @param U $default * @return T */ #[Override] @@ -84,18 +85,31 @@ public function unwrapOrElse(callable $fn): mixed return $this->value; } + /** + * @template U + * + * @param callable(T): U $fn + * + * @return Ok + */ #[Override] public function map(callable $fn): Result { return new self($fn($this->value)); } + /** + * @return $this + */ #[Override] public function mapErr(callable $fn): Result { return $this; } + /** + * @return $this + */ #[Override] public function inspect(callable $fn): Result { @@ -104,6 +118,9 @@ public function inspect(callable $fn): Result return $this; } + /** + * @return $this + */ #[Override] public function inspectErr(callable $fn): Result { @@ -112,8 +129,9 @@ public function inspectErr(callable $fn): Result /** * @template U + * @template V * - * @param U $default + * @param V $default * @param callable(T): U $fn * * @return U @@ -126,8 +144,9 @@ public function mapOr(mixed $default, callable $fn): mixed /** * @template U + * @template V * - * @param callable(): U $default_fn + * @param callable(): V $default_fn * @param callable(T): U $fn * * @return U @@ -150,12 +169,18 @@ public function andThen(callable $fn): Result return $fn($this->value); } + /** + * @return $this + */ #[Override] public function or(Result $res): Result { return $this; } + /** + * @return $this + */ #[Override] public function orElse(callable $fn): Result { diff --git a/src/Result.php b/src/Result.php index 9019b29..e358e3e 100644 --- a/src/Result.php +++ b/src/Result.php @@ -7,8 +7,10 @@ /** * Result型は、成功(Ok)または失敗(Err)を表現します。 * - * @template T 成功時の値の型 - * @template E 失敗時のエラーの型 + * @template-covariant T 成功時の値の型 + * @template-covariant E 失敗時のエラーの型 + * + * @phpstan-sealed Ok|Err */ interface Result { @@ -53,14 +55,14 @@ public function isErrAnd(callable $fn): bool; /** * 成功値を返します。失敗の場合は例外を投げます. * - * @return ($this is Ok ? T : never) + * @return ($this is Ok ? T : never) */ public function unwrap(): mixed; /** * エラー値を返します。成功の場合は例外を投げます. * - * @return ($this is Err ? E : never) + * @return ($this is Err ? E : never) */ public function unwrapErr(): mixed; @@ -69,7 +71,7 @@ public function unwrapErr(): mixed; * * @template U * @param U $default - * @return ($this is Ok ? T : U) + * @return ($this is Ok ? T : U) */ public function unwrapOr(mixed $default): mixed; @@ -79,7 +81,7 @@ public function unwrapOr(mixed $default): mixed; * @template U * @param callable(E): U $fn * - * @return T|U + * @return ($this is Ok ? T : U) */ public function unwrapOrElse(callable $fn): mixed; @@ -131,7 +133,7 @@ public function inspectErr(callable $fn): self; * @param U $default * @param callable(T): U $fn * - * @return T|U + * @return U */ public function mapOr(mixed $default, callable $fn): mixed; @@ -151,43 +153,51 @@ public function mapOrElse(callable $default_fn, callable $fn): mixed; * 成功の場合は第2の結果を返し、失敗の場合は最初のエラーを返します. * * @template U + * @template F * - * @param Result $res + * @param Result $res * - * @return Result + * @return Result */ public function and(self $res): self; /** * 成功の場合は関数を適用し、失敗の場合は現在のエラーを返します. * + * 関数は元と異なるエラー型を返せます。エラー型は E|F に合成されます. + * * @template U + * @template F * - * @param callable(T): Result $fn + * @param callable(T): Result $fn * - * @return Result + * @return Result */ public function andThen(callable $fn): self; /** * 失敗の場合は第2の結果を返し、成功の場合は最初の値を返します. * + * @template U * @template F * - * @param Result $res + * @param Result $res * - * @return Result + * @return Result */ public function or(self $res): self; /** * 失敗の場合は関数を適用し、成功の場合は現在の値を返します. * + * 関数は元と異なる成功型を返せます。成功型は T|U に合成されます. + * + * @template U * @template F * - * @param callable(E): Result $fn + * @param callable(E): Result $fn * - * @return Result + * @return Result */ public function orElse(callable $fn): self; diff --git a/tests/ErrTest.php b/tests/ErrTest.php index afe86fa..30b5aa9 100644 --- a/tests/ErrTest.php +++ b/tests/ErrTest.php @@ -36,7 +36,7 @@ public function isOkAnd_always_returns_false(): void #[Test] public function isErrAnd_whenCallbackReturnsTrue_returns_true(): void { - $err = new Err('critical'); + $err = new Err(self::asString('critical')); $result = $err->isErrAnd(fn ($error) => $error === 'critical'); $this->assertTrue($result); } @@ -44,7 +44,7 @@ public function isErrAnd_whenCallbackReturnsTrue_returns_true(): void #[Test] public function isErrAnd_whenCallbackReturnsFalse_returns_false(): void { - $err = new Err('warning'); + $err = new Err(self::asString('warning')); $result = $err->isErrAnd(fn ($error) => $error === 'critical'); $this->assertFalse($result); } @@ -114,7 +114,7 @@ public function unwrapOrElse_calls_function(): void #[Test] public function unwrapOrElse_receives_error_value(): void { - $err = new Err(404); + $err = new Err(self::asInt(404)); $result = $err->unwrapOrElse(fn ($code) => $code === 404 ? 'Not Found' : 'Unknown'); $this->assertSame('Not Found', $result); } @@ -215,6 +215,16 @@ public function andThen_returns_self(): void $this->assertSame('error', $result->unwrapErr()); } + #[Test] + public function andThen_withDifferentErrorType_keeps_original_error(): void + { + $err = new Err(self::asString('validation failed')); + $result = $err->andThen(fn ($x) => new Err(new \DomainException('unreachable'))); + + $this->assertSame($err, $result); + $this->assertSame('validation failed', $result->unwrapErr()); + } + #[Test] public function or_withAnotherErr_returns_second_err(): void { @@ -234,6 +244,16 @@ public function orElse_canReturnAnotherErr_returns_new_err(): void $this->assertSame('HTTP Error: 404', $result->unwrapErr()); } + #[Test] + public function orElse_withDifferentSuccessType_recovers(): void + { + $err = new Err(self::asString('original')); + $result = $err->orElse(fn ($e) => new Ok(\strlen($e))); + + $this->assertInstanceOf(Ok::class, $result); + $this->assertSame(8, $result->unwrap()); + } + #[Test] public function match_calls_err_function(): void { @@ -314,4 +334,20 @@ public function exceptionAsErrorValue_handles_exception(): void $handled = $err->mapErr(fn ($e) => $e->getMessage()); $this->assertSame('Something went wrong', $handled->unwrapErr()); } + + /** + * リテラル型を int に広げます(共変テンプレートは定数型を保持するため). + */ + private static function asInt(int $value): int + { + return $value; + } + + /** + * リテラル型を string に広げます(共変テンプレートは定数型を保持するため). + */ + private static function asString(string $value): string + { + return $value; + } } diff --git a/tests/OkTest.php b/tests/OkTest.php index 787bcaa..309282c 100644 --- a/tests/OkTest.php +++ b/tests/OkTest.php @@ -28,7 +28,7 @@ public function isErr_returns_false(): void #[Test] public function isOkAnd_whenCallbackReturnsTrue_returns_true(): void { - $ok = new Ok(10); + $ok = new Ok(self::asInt(10)); $result = $ok->isOkAnd(fn ($value) => $value > 5); $this->assertTrue($result); } @@ -36,7 +36,7 @@ public function isOkAnd_whenCallbackReturnsTrue_returns_true(): void #[Test] public function isOkAnd_whenCallbackReturnsFalse_returns_false(): void { - $ok = new Ok(3); + $ok = new Ok(self::asInt(3)); $result = $ok->isOkAnd(fn ($value) => $value > 5); $this->assertFalse($result); } @@ -190,6 +190,32 @@ public function andThen_applies_function(): void $this->assertSame(20, $result->unwrap()); } + #[Test] + public function andThen_withDifferentErrorType_chains_results(): void + { + $ok = new Ok(self::asInt(10)); + $result = $ok->andThen( + fn ($x) => $x > 5 ? new Ok("value: $x") : new Err(new \DomainException('too small')), + ); + + $this->assertInstanceOf(Ok::class, $result); + $this->assertSame('value: 10', $result->unwrap()); + } + + #[Test] + public function andThen_withDifferentErrorType_returns_new_err(): void + { + $ok = new Ok(self::asInt(3)); + $result = $ok->andThen( + fn ($x) => $x > 5 ? new Ok("value: $x") : new Err(new \DomainException('too small')), + ); + + $this->assertInstanceOf(Err::class, $result); + $error = $result->unwrapErr(); + $this->assertInstanceOf(\DomainException::class, $error); + $this->assertSame('too small', $error->getMessage()); + } + #[Test] public function or_returns_self(): void { @@ -286,4 +312,12 @@ public function chainingOperations_applies_transformations(): void $this->assertInstanceOf(Ok::class, $result); $this->assertSame(22, $result->unwrap()); // (10 * 2) + 5 - 3 = 22 } + + /** + * リテラル型を int に広げます(共変テンプレートは定数型を保持するため). + */ + private static function asInt(int $value): int + { + return $value; + } } diff --git a/tests/Types/result.php b/tests/Types/result.php new file mode 100644 index 0000000..9dc544e --- /dev/null +++ b/tests/Types/result.php @@ -0,0 +1,203 @@ + (= Result) を + * Result として返せる. + * + * @return Result + */ +function parsePositiveInt(string $input): Result +{ + $value = (int) $input; + if ($value > 0) { + return new Ok($value); + } + + return new Err(new RuntimeException('not a positive int')); +} + +/** + * @return Result + */ +function findNameById(int $id): Result +{ + if ($id === 42) { + return new Ok('Alice'); + } + + return new Err(new LogicException('not found')); +} + +/** + * @param Result $result + */ +function testAndThenComposesErrorTypes(Result $result): void +{ + // andThen は異なるエラー型を返すコールバックを受け取れ、エラー型は E|F に合成される + $chained = $result->andThen(findNameById(...)); + assertType('Valbeat\Result\Result', $chained); +} + +/** + * @param Result $result + * @param Result $other + */ +function testAndComposesErrorTypes(Result $result, Result $other): void +{ + assertType('Valbeat\Result\Result', $result->and($other)); +} + +/** + * @param Result $result + */ +function testOrElseComposesSuccessTypes(Result $result): void +{ + // orElse は異なる成功型を返すコールバックを受け取れ、成功型は T|U に合成される + $recovered = $result->orElse(static fn (RuntimeException $e): Result => findNameById(0)); + assertType('Valbeat\Result\Result', $recovered); +} + +/** + * @param Result $result + * @param Result $other + */ +function testOrComposesSuccessTypes(Result $result, Result $other): void +{ + assertType('Valbeat\Result\Result', $result->or($other)); +} + +/** + * @param Result $result + */ +function testIsOkNarrowing(Result $result): void +{ + if ($result->isOk()) { + assertType('Valbeat\Result\Ok', $result); + assertType('int', $result->unwrap()); + } else { + assertType('Valbeat\Result\Err', $result); + assertType('RuntimeException', $result->unwrapErr()); + } +} + +/** + * sealed のテスト: instanceof Ok の else 分岐で Err に絞り込まれる. + * + * @param Result $result + */ +function testInstanceofNarrowing(Result $result): bool +{ + if ($result instanceof Ok) { + return true; + } + // sealed の効果: instanceof Ok の else 分岐で Err に絞り込まれる + assertType('Valbeat\Result\Err', $result); + + return false; +} + +/** + * sealed の効果: Ok と Err で全ケース網羅と判断され、match が非網羅エラーにならない. + * + * @param Result $result + */ +function testExhaustiveMatch(Result $result): string +{ + return match (true) { + $result instanceof Ok => 'ok', + $result instanceof Err => 'err', + }; +} + +/** + * ネストした Result の平坦化: andThen が内側の成功型と両エラー型の合成を推論できる. + * + * @param Result, LogicException> $nested + */ +function testNestedResultFlattening(Result $nested): void +{ + $flattened = $nested->andThen(static fn (Result $inner): Result => $inner); + assertType('Valbeat\Result\Result', $flattened); +} + +/** + * 具象 Ok レシーバでは no-op 側のメソッドが実行時に起こり得ない型を混ぜない. + * + * @param Ok $ok + */ +function testConcreteOkPrecision(Ok $ok): void +{ + // or/orElse は $this を返すため Ok のまま + assertType('Valbeat\Result\Ok', $ok->orElse(static fn (never $e): Result => findNameById(0))); + assertType('Valbeat\Result\Ok', $ok->or(findNameById(0))); + // mapErr は no-op + assertType('Valbeat\Result\Ok', $ok->mapErr(static fn (never $e): LogicException => $e)); + // map は Ok を返す + assertType('Valbeat\Result\Ok', $ok->map(stringify(...))); + // mapOr/mapOrElse は常に $fn の結果(デフォルト値の型は混ざらない) + assertType('string', $ok->mapOr(0.5, stringify(...))); + assertType('string', $ok->mapOrElse(static fn (): float => 0.5, stringify(...))); +} + +/** + * 具象 Err レシーバでは no-op 側のメソッドが実行時に起こり得ない型を混ぜない. + * + * @param Err $err + */ +function testConcreteErrPrecision(Err $err, float $fallback): void +{ + // and/andThen は $this を返すため Err のまま + assertType('Valbeat\Result\Err', $err->andThen(static fn (never $v): Result => findNameById(0))); + assertType('Valbeat\Result\Err', $err->and(findNameById(0))); + // map は no-op + assertType('Valbeat\Result\Err', $err->map(static fn (never $v): int => $v)); + // mapErr は Err を返す + assertType('Valbeat\Result\Err', $err->mapErr(static fn (RuntimeException $e): LogicException => new LogicException($e->getMessage()))); + // mapOr/mapOrElse は常にデフォルト側($fn の戻り値型は混ざらない) + assertType('float', $err->mapOr($fallback, static fn (never $v): string => $v)); + assertType('float', $err->mapOrElse(static fn (): float => $fallback, static fn (never $v): string => $v)); +} + +function stringify(int $value): string +{ + return 'value: ' . $value; +} + +/** + * @param Result $result + */ +function testUnwrapVariants(Result $result, string $default, float $fallback): void +{ + assertType('int|string', $result->unwrapOr($default)); + assertType('float|int', $result->unwrapOrElse(static fn (RuntimeException $e): float => $fallback)); + assertType('string', $result->mapOr($default, stringify(...))); +} + +/** + * @param Result $result + */ +function testMap(Result $result): void +{ + assertType( + 'Valbeat\Result\Result', + $result->map(stringify(...)), + ); + assertType( + 'Valbeat\Result\Result', + $result->mapErr(static fn (RuntimeException $e): LogicException => new LogicException($e->getMessage())), + ); +}