Skip to content

feat: throw UnwrapException with value context from unwrap()/unwrapErr()#94

Merged
valbeat merged 4 commits into
mainfrom
feat/unwrap-exception
Jul 7, 2026
Merged

feat: throw UnwrapException with value context from unwrap()/unwrapErr()#94
valbeat merged 4 commits into
mainfrom
feat/unwrap-exception

Conversation

@valbeat

@valbeat valbeat commented Jul 7, 2026

Copy link
Copy Markdown
Owner

概要

コードレビューで指摘した例外設計の改善です。現状 unwrap() / unwrapErr() は素の LogicException を投げるため、(1) 利用側が unwrap 失敗だけを選択的に catch できず、(2) メッセージに保持値の情報がなくデバッグ時に不便でした(Rust の panic はエラー値の Debug 表現を含みます)。

変更内容

  • UnwrapExceptionLogicException を継承)を新設
  • Err->unwrap() / Ok->unwrapErr() はこの例外を投げ、メッセージに値の要約を含める:
    • Throwable: クラス名 + メッセージ(例: RuntimeException: boom
    • Stringable: クラス名 + 文字列表現
    • その他オブジェクト: クラス名のみ(機微情報を漏らさないため中身はダンプしない)
    • スカラー / null: var_export 表現
    • 配列など: get_debug_type
  • インターフェースの unwrap() / unwrapErr()@throws UnwrapException を追記
  • README の "throws LogicException" 記述を更新

BC への影響

なし。LogicException を継承し、従来のメッセージはプレフィックスとして保持しているため、既存の catch (\LogicException)expectExceptionMessage('called Result::unwrap() on an Err value') はそのまま通ります(既存テストは無変更でパス)。

TDD

  1. 例外クラス・メッセージ内容をピン留めするテスト 6 件を追加し RED を確認してコミット
  2. 実装して GREEN: 75 tests / 122 assertions、PHPStan max エラーなし、cs-fixer クリーン

https://claude.ai/code/session_017XTM7pxbWPVNLV639i5WgK

valbeat added 2 commits July 7, 2026 13:07
unwrap() on Err and unwrapErr() on Ok should throw a dedicated
UnwrapException (extending LogicException for BC) whose message
includes a description of the contained value, mirroring Rust's
panic message which includes the Debug representation.

Currently RED: the implementation throws plain LogicException
without value context.

Claude-Session: https://claude.ai/code/session_017XTM7pxbWPVNLV639i5WgK
Introduce a dedicated UnwrapException so callers can catch unwrap
misuse specifically, and include a description of the contained value
in the message (class + message for Throwables, class + string for
Stringables, class name for other objects, var_export for scalars),
mirroring Rust's panic output for Result::unwrap().

UnwrapException extends LogicException, so existing catch sites and
the previous message prefix keep working.

Claude-Session: https://claude.ai/code/session_017XTM7pxbWPVNLV639i5WgK
@valbeat valbeat self-assigned this Jul 7, 2026
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@valbeat, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 51 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 777ffc94-6524-42bc-8249-a29a81047ca8

📥 Commits

Reviewing files that changed from the base of the PR and between 98a2fcb and 336e8ae.

📒 Files selected for processing (7)
  • README.md
  • src/Err.php
  • src/Ok.php
  • src/Result.php
  • src/UnwrapException.php
  • tests/ErrTest.php
  • tests/OkTest.php
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/unwrap-exception

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (98a2fcb) to head (336e8ae).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@             Coverage Diff             @@
##                main       #94   +/-   ##
===========================================
  Coverage     100.00%   100.00%           
- Complexity        40        54   +14     
===========================================
  Files              2         3    +1     
  Lines             80       107   +27     
===========================================
+ Hits              80       107   +27     
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a custom UnwrapException (extending \LogicException) to provide detailed context when calling unwrap() on an Err value or unwrapErr() on an Ok value, mimicking Rust's panic messages. The changes update the implementation, documentation, and tests accordingly. The review feedback suggests improving robustness in UnwrapException::describe() by handling potential exceptions thrown during Stringable string casting to avoid masking the original exception, and adding a corresponding test case to verify this fallback behavior.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/UnwrapException.php
Comment on lines +34 to +43
private static function describe(mixed $value): string
{
return match (true) {
$value instanceof \Throwable => \sprintf('%s: %s', $value::class, $value->getMessage()),
$value instanceof \Stringable => \sprintf('%s: %s', $value::class, (string) $value),
\is_object($value) => $value::class,
\is_scalar($value), null === $value => var_export($value, true),
default => get_debug_type($value),
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Stringable インターフェースを実装したオブジェクトを文字列キャストする際、__toString() メソッド内で例外(Throwable)がスローされる可能性があります。

もし __toString() が例外をスローした場合、本来スローされるべき UnwrapException がその例外によって上書き(マスク)されてしまい、unwrap 失敗のデバッグが困難になります。

これを防ぐため、Stringable のキャスト処理を try-catch ブロックで囲み、例外が発生した場合はクラス名のみを返すようにフォールバックする設計に改善することをお勧めします。

    private static function describe(mixed $value): string
    {
        return match (true) {
            $value instanceof \\Throwable => \\sprintf('%s: %s', $value::class, $value->getMessage()),
            $value instanceof \\Stringable => self::describeStringable($value),
            \\is_object($value) => $value::class,
            \\is_scalar($value), null === $value => \\var_export($value, true),
            default => \\get_debug_type($value),
        };
    }

    private static function describeStringable(\\Stringable $value): string
    {
        try {
            return \\sprintf('%s: %s', $value::class, (string) $value);
        } catch (\\Throwable) {
            return $value::class;
        }
    }

Comment thread tests/OkTest.php
Comment on lines +102 to +115
#[Test]
public function unwrapErr_withStringableValue_includesClassAndString(): void
{
$value = new class () implements \Stringable {
public function __toString(): string
{
return 'stringable value';
}
};
$ok = new Ok($value);
$this->expectException(UnwrapException::class);
$this->expectExceptionMessage('stringable value');
$ok->unwrapErr();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

__toString() が例外をスローした場合でも、UnwrapException が正しくスローされ、クラス名にフォールバックされることを検証するテストケースを追加することをお勧めします。

    #[Test]
    public function unwrapErr_withStringableValue_includesClassAndString(): void
    {
        $value = new class () implements \\Stringable {
            public function __toString(): string
            {
                return 'stringable value';
            }
        };
        $ok = new Ok($value);
        $this->expectException(UnwrapException::class);
        $this->expectExceptionMessage('stringable value');
        $ok->unwrapErr();
    }

    #[Test]
    public function unwrapErr_withStringableValueThatThrows_fallsBackToClassName(): void
    {
        $value = new class () implements \\Stringable {
            public function __toString(): string
            {
                throw new \\RuntimeException('toString failed');
            }
        };
        $ok = new Ok($value);
        $this->expectException(UnwrapException::class);
        $this->expectExceptionMessage('called Result::unwrapErr() on an Ok value:');
        $ok->unwrapErr();
    }

@valbeat valbeat marked this pull request as ready for review July 7, 2026 04:35
Copilot AI review requested due to automatic review settings July 7, 2026 04:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves the ergonomics and debuggability of Result::unwrap() / Result::unwrapErr() failures by introducing a dedicated UnwrapException (extending LogicException) and including a summarized representation of the held value in the exception message, plus corresponding documentation and tests.

Changes:

  • Add Valbeat\Result\UnwrapException with helper constructors and value summarization for exception messages.
  • Switch Err->unwrap() and Ok->unwrapErr() to throw UnwrapException instead of a raw LogicException.
  • Update Result phpdoc (@throws) and README, and add PHPUnit coverage for message formatting/catchability.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/OkTest.php Adds tests asserting unwrapErr() throws UnwrapException and includes value context in the message.
tests/ErrTest.php Adds tests asserting unwrap() throws UnwrapException, message formatting for different error types, and catchability as LogicException.
src/UnwrapException.php New exception type with message construction and value summarization logic.
src/Result.php Adds @throws UnwrapException annotations to unwrap() / unwrapErr().
src/Ok.php Updates unwrapErr() to throw UnwrapException with value context.
src/Err.php Updates unwrap() to throw UnwrapException with error context.
README.md Updates documentation to mention UnwrapException instead of LogicException.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/UnwrapException.php
Comment on lines +34 to +43
private static function describe(mixed $value): string
{
return match (true) {
$value instanceof \Throwable => \sprintf('%s: %s', $value::class, $value->getMessage()),
$value instanceof \Stringable => \sprintf('%s: %s', $value::class, (string) $value),
\is_object($value) => $value::class,
\is_scalar($value), null === $value => var_export($value, true),
default => get_debug_type($value),
};
}
Comment thread tests/ErrTest.php
Comment on lines +94 to +99
try {
$err->unwrap();
} catch (\LogicException $e) {
$this->assertInstanceOf(UnwrapException::class, $e);
}
}
valbeat added 2 commits July 7, 2026 13:38
- a Stringable whose __toString() throws must not replace
  UnwrapException with the escaping exception
- enum error values must keep their case name in the message
- long string values must be truncated to bound message size
- multiline string values must not break single-line log formats

Currently RED.

Claude-Session: https://claude.ai/code/session_017XTM7pxbWPVNLV639i5WgK
- a Stringable whose __toString() throws no longer replaces
  UnwrapException with the escaping exception (falls back to the
  class name)
- enum error values keep their case name (Status::NotFound) instead
  of collapsing to the bare class name
- summaries are normalized to a single line and truncated at 120
  chars, bounding message size and keeping log lines intact
- anonymous class names drop the file-path suffix so the value part
  is not pushed past the truncation limit

Claude-Session: https://claude.ai/code/session_017XTM7pxbWPVNLV639i5WgK
valbeat added a commit that referenced this pull request Jul 7, 2026
Stack this PR on #94 per review, resolving overlapping test and
README hunks. expect()/expectErr() switch to UnwrapException in the
following commits.

Claude-Session: https://claude.ai/code/session_017XTM7pxbWPVNLV639i5WgK
@valbeat valbeat merged commit d9f74e1 into main Jul 7, 2026
9 checks passed
@valbeat valbeat deleted the feat/unwrap-exception branch July 7, 2026 06:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants