/var/log/jsoizo

メモ帳 技術とか趣味とか

PHPで型安全なエラーハンドリングをやりたくてResult型ライブラリを作った

こういうライブラリを作った。

packagist.org

PHPでResult型が必要な理由

PHPの標準的なエラーハンドリングは例外に依存している。ただ、例外ベースのアプローチにはいくつか問題がある。

まず、例外は関数シグネチャに現れない。function getUser(int $id): User というシグネチャからは、この関数が UserNotFoundExceptionDatabaseException を投げる可能性があることが読み取れない。PHPDocの @throws アノテーションは書けるが、強制力がない。

また、例外のハンドリングを強制できない。try-catchを書き忘れても、コンパイラ(や静的解析ツール)は警告しない。結果として、想定外の例外がアプリケーションの上位層まで伝播し、500エラーになる。

Result型はこれらの問題に対する解決策で、エラーを「値」として扱い、関数の戻り値型に明示することで、エラーハンドリングを型システムで強制できるようになる。

// 例外ベース: エラーの可能性がシグネチャから見えない
function getUser(int $id): User { ... }

// Result型: 失敗する可能性が型で明示される
/** @return Result<User, UserNotFound|DatabaseError> */
function getUser(int $id): Result { ... }

呼び出し側は Result を受け取った時点で、成功・失敗の両方を処理する責任を負う。PHPStanを使えば、この処理漏れを静的解析で検出できる。

PHPでのResult型の必要性などは以下のスライドがわかりやすい。

Result型で“失敗”を型にするPHPコードの書き方 - Speaker Deck

なお、PHPに限らずResult型の必要性はあらゆるところで議論されているものの、ジェネリクスが言語レベルで存在しないPHPにおいてはまだまだ一般的になっているとは感じない。仕事で書いているPHPコードにもまだ入れられていないし。

自作したモチベーション

既存のPHPでResult型を実装したライブラリ、例えば最も普及しているgraham-campbell/result-typeは存在するのだが、以下のような課題を抱えている。

  • graham-campbell/result-typeはmap/flatMap/mapError程度で、recoverfoldがない
  • 基本的な@templateによる型付けはあるが、match式の網羅性チェックがない
  • phpoption/phpoptionに依存している
  • PHP 7.2+対応のため、never型や共変性が活用できない

Issue #3API拡張のリクエストがあるが対応されておらず、自分で作ることにした。

このライブラリで出来ること

実用的なAPIの提供

ScalaのEither/Try、RustのResult、Kotlinのarrow-ktを参考に、実際の開発で必要になるメソッドを一通り実装した。

メソッド 説明
map($fn) 成功値を変換
mapError($fn) エラー値を変換
flatMap($fn) Resultを返す操作をチェーン
fold($onFailure, $onSuccess) 両方のケースを処理して値を返す
recover($fn) 値でエラーから回復
recoverWith($fn) Resultでエラーから回復
tap($fn) / tapError($fn) 副作用を実行(ログ出力等)
getOrElse($default) 値またはデフォルトを取得
getOrNull() 成功なら値、失敗ならnull

ネストを防ぐbindingブロック

複数の処理を flatMap で合成すると、ネストが深くなる。

$result = Result::catch(fn() => $orderRepo->find($orderId))
    ->flatMap(fn($order) =>
        Result::catch(fn() => $order->loadItems())
            ->flatMap(fn($items) =>
                Result::catch(fn() => $this->validate($items))
                    ->flatMap(fn($validated) =>
                        Result::catch(fn() => $this->process($validated))
                    )
            )
    );

バリデーションなど複数のResultを処理する必要があるときに厄介である。

ここで、arrow-ktのEitherでいうところのeither {}ブロックのように、ネストを防ぐためにResult::bindingというstatic関数を用意している。途中でFailureが発生した場合、そこで処理が短絡(early return)される。

$result = Result::binding(function () use ($orderId) {
    /** @var Order $order */
    $order = yield Result::catch(fn() => $orderRepo->find($orderId));
    /** @var list<Item> $items */
    $items = yield Result::catch(fn() => $order->loadItems());
    /** @var list<Item> $validated */
    $validated = yield Result::catch(fn() => $this->validate($items));
    return $this->process($validated);
});

PHPのジェネレータの制約上、yieldの戻り値の型推論が効かないのでPHPDocコメントが多くなるのが欠点。このぱっとみたときのわかりづらさと、インデントが深くなることの辛さのどちらを取るかが悩ましい。

PHPStanとのインテグレーション

静的解析ツールPHPStanのアノテーション機能を極力使うようにして、安全に扱えるようにしている。PHPStanのlevel設定は9〜10くらいの厳しさで設定したときに適切にエラーが出てくれる。

Sealed Class

Result型が@phpstan-sealedを使用してsealedクラスとしてマークされているので、Success/Failure以外から継承したときにPHPStanに怒られるようになっている。

ただ@phpstan-sealedアノテーションはあくまで「指定したクラス以外からの継承を許さない」ことしか検知しないので、ScalaやKotlinで代数的データ型の実装のためにsealedを利用している身としては足りなさを感じる。

Match式の網羅性チェック(カスタムルール)

このライブラリとしてカスタムのルールを設けており、match式の中でSuccess/Failureの片方しか網羅できていない場合にエラーが吐かれるようにしている。

// PHPStanエラー: Match expression on Result type is not exhaustive. Missing: Failure.
match (true) {
    $result instanceof Success => 'success',
};

型ナローイング

isSuccess()/isFailure()の結果に応じて、PHPStanが型を適切に推論する。

if ($result->isSuccess()) {
    // PHPStanがSuccess<T, E>と推論 → get()が安全に呼べる
    $value = $result->get();
} else {
    // PHPStanがFailure<T, E>と推論 → getError()が安全に呼べる
    $error = $result->getError();
}

Zero Dependency

Result型という基盤的な型のために、他のライブラリを引き込みたくない。

graham-campbell/result-typeはOption型を提供するphpoption/phpoptionに依存するが、本ライブラリは依存ゼロで実装している。シンプルなエラーハンドリングをしたいだけなのに、Composer依存ツリーが肥大化することを避けられる。

他言語との比較と設計判断

本ライブラリはRustのResult<T, E>ScalaのEither/Try、Kotlinのarrow-ktを参考にしている。ただし、PHPの言語仕様の制約により、完全に同じ体験を提供できない部分がある。

パターンマッチがない

Rustではこう書ける:

match result {
    Ok(value) => println!("Success: {}", value),
    Err(e) => println!("Error: {}", e),
}

PHPには言語レベルのパターンマッチがないため、match式とinstanceofの組み合わせで代替している:

match (true) {
    $result instanceof Success => "Success: " . $result->get(),
    $result instanceof Failure => "Error: " . $result->getError(),
};

あるいはfoldメソッドで両方のケースを処理する:

$result->fold(
    fn($error) => "Error: " . $error,
    fn($value) => "Success: " . $value,
);

match (true) という書き方はなかなか気持ちが悪いし、このブロックの中に好きに判定条件を書けてしまうのがつらいところ。

ジェネリクスがランタイムに存在しない

Rustやその他の言語ではジェネリクスコンパイル時に検証される。PHPにはランタイムのジェネリクスがないため、型安全性はPHPStanのPHPDocアノテーションに完全に依存している。

PHPStanを使わないプロジェクトでは、この型情報は単なるコメントになってしまう。逆に言えば、PHPStanを導入していれば他言語と遜色ない型チェックが得られる。ただ、PHPStanのlevelを上げるとメモリをドカ喰いするのでマシンスペックを要求するのが欠点。

never型による精密な型表現

共変性(@template-covariant)を活かして、ファクトリメソッドではnever型を使った精密な型を返すようにしている:

// Successは絶対にエラーを持たない → E = never
public static function success(mixed $value): Success<TValue, never>

// Failureは絶対に成功値を持たない → T = never
public static function failure(mixed $error): Failure<never, TError>

neverは「この型の値は存在しない」ことを表す。neverはすべての型のサブタイプなので、Success<string, never>Result<string, Exception>に代入可能になる。

このあたりがPHPの古いバージョンを切ることで得られる恩恵となっている。

共変性とrecoverの型安全性のトレードオフ

正直なところ、現状の実装は妥協の産物である。

共変性(@template-covariant)を導入した理由は、Success<T, never>Result<T, E> に自然に代入できるようにするためだった。しかし、これにより recover の型シグネチャで問題が発生した。

/**
 * @param callable(E): T $fn
 * @return Result<T, E>
 * @phpstan-ignore generics.variance
 */
abstract public function recover(callable $fn): Result;

Tは共変(出力位置のみで使用可能)として宣言しているが、callable(E): T の戻り値型としてTを使っている。callableの戻り値は呼び出し側から見ると「入力」にあたるため、これは反変位置での使用となり、共変性に違反する。PHPStanはScalaのような下限境界(lower bound)をサポートしていないため、@phpstan-ignore で抑制せざるを得なかった。

代替案として、共変性を諦めることを考えている。

// 共変性なしの場合、こういうメソッドで明示的に型を広げる
/** @return Result<T, E2> */
public function widenError(): Result { return $this; }

より良いアプローチがあれば模索したい。

まとめ

PHPという型で守ることが難しい言語の枠組みの中で、今回作ったライブラリはこういうアプローチでエラーハンドリングを安全に扱えるようにしている。

  • PHPStan Level 9-10での型チェック
  • カスタムルールによる網羅性チェック
  • 他言語のResult型とほぼ同等のAPI
  • 依存なし

PHPStanを導入しているプロジェクトで、例外の伝播範囲を明確に制御したい場合に効果を発揮するんじゃないかなー。