【C#】例外フィルター(when句)完全ガイド|スタック保全・ログパターン・パターンマッチング・実践例まで

【C#】例外フィルター(when句)を使ったエラー処理の書き方 C#

C# 6 で導入された例外フィルター(when 句)は「条件が合うときだけ catch する」という単純な機能ではありません。フィルターが false のときスタックが巻き戻されないという性質により、デバッガーの挙動・スタックトレースの保全・ログ専用フィルターなど、catch 内での if-rethrow とは本質的に異なる使い方ができます。

本記事では when の基本から、スタック巻き戻し動作・ログ専用パターン・パターンマッチングとの組み合わせ・実務での活用例まで体系的に解説します。try-catch の基礎・throw vs throw ex・カスタム例外は例外処理完全ガイドを参照してください。

スポンサーリンク

when 句の基本構文

catch (ExType ex) when (条件) の形で記述します。条件が true のときのみ catch ブロックが実行され、false のときは次の catch ブロック(またはそのまま上位)へ例外が伝播します。

when 句の基本
// 基本形: 同じ例外型で条件を分ける
try
{
    int.Parse("12345678901234567890"); // OverflowException が発生
}
catch (FormatException ex) when (ex.Message.Contains("Input string"))
{
    Console.WriteLine("数値以外の文字が含まれています");
}
catch (OverflowException ex) when (ex.Message.Contains("too large"))
{
    Console.WriteLine("数値が大きすぎます");
}
catch (OverflowException)
{
    Console.WriteLine("その他のオーバーフローエラー");
}

// 複数の同一例外型に when を付けて分岐させる
try
{
    var code = GetErrorCode();  // 0〜4 の値を返す
    throw new InvalidOperationException($"エラーコード: {code}");
}
catch (InvalidOperationException ex) when (ex.Message.Contains("コード: 1"))
{
    Console.WriteLine("一時的なエラー → 再試行");
}
catch (InvalidOperationException ex) when (ex.Message.Contains("コード: 2"))
{
    Console.WriteLine("権限エラー → ログアウト処理");
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"その他のエラー: {ex.Message}");
}

when がスタックを巻き戻さない — 最重要の特性

when フィルターはスタックが巻き戻される前に評価されます。フィルターが false を返した場合、catch ブロックに入らず、スタックはそのまま保持されます。これは catch 内で条件チェックして throw するパターンとは根本的に異なります。

スタック巻き戻しの違い — when vs catch + rethrow
// NG パターン: catch してから条件チェックして再スロー
try
{
    // 例外の発生源(Inner メソッド)
    Inner();
}
catch (Exception ex)
{
    if (ex.Message.Contains("一時的"))
    {
        // ここで catch → スタックはすでに巻き戻されている
        // → throw でも発生源(Inner)の情報が薄くなる場合がある
        throw;  // スタックトレースは保持されるが一度 catch に入っている
    }
    // 条件に合わない → もう一度スロー
    throw;
}

// OK パターン: when フィルターを使う
try
{
    Inner();
}
catch (Exception ex) when (ex.Message.Contains("一時的"))
{
    // フィルターが true の場合のみここに入る
    // フィルターが false の場合: スタックは一切巻き戻されず次の catch へ
    Console.WriteLine("一時的エラー処理");
}

// デバッガーでの挙動の違い:
// when(false) の場合: "例外がスローされた場所" でデバッガーが止まる(break on throw)
// catch + rethrow の場合: catch の位置でデバッガーが止まる(発生源が分かりにくい)
比較観点 catch 内 if + throw when フィルター(false)
スタック巻き戻し 発生する(catch に一度入る) 発生しない(catch に入らない)
デバッガーの停止位置 catch ブロック 例外の発生源(throw された行)
パフォーマンス 例外オブジェクト構築コスト後に処理 キャッチしない場合のオーバーヘッドが少ない
コードの可読性 catch 内が複雑になりやすい 条件が catch の宣言部に明示される
finally の実行 catch に入って throw → finally が実行される false でも finally は実行される(例外伝播時もスコープを抜けるため)
when フィルターとデバッガーの連動
Visual Studio などのデバッガーで「例外が発生したときに常に停止」を有効にしている場合、when フィルターが false を返すと、デバッガーは例外がスローされた場所(throw の行)で停止します。catch 内で rethrow するパターンでは catch の場所で停止するため、発生源の特定が遅れます。特に深いコールスタックのバグ調査で when が威力を発揮します。

ログ専用フィルター — 常に false を返すパターン

when の条件式はメソッド呼び出しも可能です。「常に false を返すログメソッド」を使うと、例外を飲み込まずにログだけ記録するフィルターを書けます。これはスタックが巻き戻されないため、例外が発生した場所のフルスタックトレースをログに記録できるという大きなメリットがあります。

ログ専用フィルターパターン
// ログメソッド: 常に false を返す(例外を飲み込まない)
static bool LogException(Exception ex, string context = "")
{
    // ILogger / Serilog / Console など任意のロガーに書く
    Console.Error.WriteLine(
        $"[EXCEPTION] {context} | {ex.GetType().Name}: {ex.Message}
{ex.StackTrace}");
    return false;  // 必ず false を返す → catch ブロックには入らない
}

// 使い方: when(LogException()) が false を返すので catch には入らない
// → 例外はそのまま上位に伝播する(ログだけ記録される)
try
{
    RiskyOperation();
}
catch (Exception ex) when (LogException(ex, "RiskyOperation"))
{
    // ここには入らない(LogException が常に false を返すため)
}

// より実践的な使い方: 特定の例外だけログして、後の catch に処理を委ねる
try
{
    ProcessOrder(orderId);
}
catch (Exception ex) when (LogException(ex, $"ProcessOrder(id={orderId})"))
{
    // ここには入らない
}
catch (OrderNotFoundException ex)
{
    Console.WriteLine($"注文が見つかりません: {ex.Message}");
}
catch (PaymentException ex)
{
    Console.WriteLine($"決済エラー: {ex.Message}");
}

// ネストして複合ログも可能
try
{
    ProcessBatch(items);
}
catch (Exception ex)
    when (LogException(ex) || ex is BatchProcessException { Severity: >= 2 })
{
    Console.WriteLine("重大なバッチエラー");
}
ログ専用フィルターがスタックトレースを完全保全できる理由
catch 内でログを取って throw する場合、catch ブロックの実行までスタックが巻き戻されています。when(LogException(ex)) パターンではスタックが巻き戻される前にログが取れるため、例外発生地点から呼び出しスタックの最深部までの完全な情報を記録できます。ASP.NET Core のグローバルエラーハンドラーや Middleware でも同様の手法が使われます。

パターンマッチングとの組み合わせ(C# 8+)

C# 8 以降、when フィルター内でパターンマッチングを使えます。例外のサブプロパティを直接分解して条件判定できるため、複雑なフィルタリングを簡潔に書けます。

when とパターンマッチングの組み合わせ
// プロパティパターン: 例外のプロパティを直接参照
try
{
    await httpClient.GetStringAsync("https://api.example.com/data");
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
    Console.WriteLine("404: リソースが見つかりません");
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
{
    Console.WriteLine("401: 認証が必要です");
}
catch (HttpRequestException ex)
    when (ex.StatusCode >= HttpStatusCode.InternalServerError)
{
    Console.WriteLine("5xx: サーバーエラー → 再試行候補");
}

// is パターン: InnerException の型チェック
catch (AggregateException ae)
    when (ae.InnerException is TimeoutException)
{
    Console.WriteLine("タイムアウト発生");
}
catch (AggregateException ae)
    when (ae.InnerException is HttpRequestException { StatusCode: HttpStatusCode.ServiceUnavailable })
{
    Console.WriteLine("サービス利用不可 (503)");
}

// カスタム例外プロパティパターン
public class ApiException : Exception
{
    public int    StatusCode { get; init; }
    public string ErrorCode  { get; init; } = "";
}

catch (ApiException ex) when (ex.StatusCode == 429)
{
    Console.WriteLine("レートリミット: バックオフ後に再試行");
}
catch (ApiException ex) when (ex.ErrorCode is "MAINTENANCE" or "SHUTDOWN")
{
    Console.WriteLine("メンテナンス中");
}
catch (ApiException ex) when (ex.StatusCode >= 400 && ex.StatusCode < 500)
{
    Console.WriteLine($"クライアントエラー: {ex.ErrorCode}");
}

実践例 — SqlException.Number による DB エラー分岐

ADO.NET の SqlException はエラー番号(Number プロパティ)でエラーの種類を識別します。when を使うと例外クラスを分けずに番号で分岐できます。

SqlException.Number による条件分岐
using Microsoft.Data.SqlClient;

// SQL Server エラー番号の主要なもの
// 1205: デッドロック
// 2601/2627: 一意制約違反
// -2: タイムアウト(SQL Client Timeout)
// 4060: データベースに接続できない
// 18456: ログイン失敗

static async Task ExecuteWithFilterAsync(string connectionString, string query)
{
    try
    {
        await using var conn = new SqlConnection(connectionString);
        await conn.OpenAsync();
        await using var cmd = new SqlCommand(query, conn);
        await cmd.ExecuteNonQueryAsync();
    }
    // デッドロック: 自動再試行対象
    catch (SqlException ex) when (ex.Number == 1205)
    {
        Console.WriteLine("デッドロック検出 → 少し待って再試行します");
        await Task.Delay(TimeSpan.FromSeconds(1));
        // 再試行ロジックをここに
    }
    // 一意制約違反
    catch (SqlException ex) when (ex.Number is 2601 or 2627)
    {
        Console.WriteLine($"重複データ: {ex.Message}");
        throw new DuplicateKeyException("既に同じキーのデータが存在します", ex);
    }
    // タイムアウト(過渡的エラー)
    catch (SqlException ex) when (ex.Number == -2)
    {
        Console.WriteLine("クエリタイムアウト");
        throw new TimeoutException("データベース操作がタイムアウトしました", ex);
    }
    // その他の SQL エラー
    catch (SqlException ex)
    {
        Console.WriteLine($"SQL エラー #{ex.Number}: {ex.Message}");
        throw;
    }
}

// カスタム例外(ドメインレイヤー用)
public class DuplicateKeyException : Exception
{
    public DuplicateKeyException(string message, Exception inner)
        : base(message, inner) { }
}

実践例 — 過渡エラーの自動再試行

when を使ったリトライポリシー
// 過渡的(transient)エラーかどうかを判定するヘルパー
static bool IsTransient(HttpRequestException ex) =>
    ex.StatusCode is
        HttpStatusCode.RequestTimeout or      // 408
        HttpStatusCode.TooManyRequests or      // 429
        HttpStatusCode.InternalServerError or  // 500
        HttpStatusCode.BadGateway or           // 502
        HttpStatusCode.ServiceUnavailable or   // 503
        HttpStatusCode.GatewayTimeout;         // 504

// シンプルな再試行ループ
static async Task<string> GetWithRetryAsync(
    HttpClient client,
    string url,
    int maxRetry = 3)
{
    for (int attempt = 1; attempt <= maxRetry; attempt++)
    {
        try
        {
            return await client.GetStringAsync(url);
        }
        // 過渡エラーかつ最後の試行でない場合は再試行
        catch (HttpRequestException ex)
            when (IsTransient(ex) && attempt < maxRetry)
        {
            var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); // 指数バックオフ
            Console.WriteLine(
                $"[試行 {attempt}/{maxRetry}] {ex.StatusCode} → {delay.TotalSeconds}秒後に再試行");
            await Task.Delay(delay);
        }
        // 過渡エラーだが最後の試行 OR 永続的エラー → そのままスロー
    }
    throw new InvalidOperationException($"最大試行回数 ({maxRetry}) を超えました");
}

// 使い方
using var httpClient = new HttpClient();
try
{
    string data = await GetWithRetryAsync(httpClient, "https://api.example.com/data");
    Console.WriteLine($"取得成功: {data.Length} chars");
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"全試行失敗: {ex.Message}");
}
catch (HttpRequestException ex)
{
    Console.WriteLine($"永続的エラー {ex.StatusCode}: {ex.Message}");
}

CancellationToken キャンセル判別

OperationCanceledExceptionTaskCanceledException の基底クラス)はキャンセルとタイムアウトの両方で投げられます。when を使ってユーザーキャンセルか強制タイムアウトかを区別できます。

キャンセル vs タイムアウトの when 分岐
// ユーザーキャンセルとタイムアウトを区別するには別々の CTS を使う
using var userCts    = new CancellationTokenSource();            // ユーザー操作用
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); // タイムアウト用
using var linkedCts  = CancellationTokenSource.CreateLinkedTokenSource(
    userCts.Token, timeoutCts.Token);  // 両方をリンク

try
{
    await DoLongOperationAsync(linkedCts.Token);
}
// タイムアウト CTS がトリガー → タイムアウトによるキャンセル
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
    Console.WriteLine("タイムアウト(5秒超過)");
}
// ユーザー CTS がトリガー → ユーザーによるキャンセル
catch (OperationCanceledException) when (userCts.IsCancellationRequested)
{
    Console.WriteLine("ユーザーによるキャンセル");
}
catch (OperationCanceledException)
{
    Console.WriteLine("その他のキャンセル");
}

// シンプルなタイムアウトのみ判定
async Task<T?> ExecuteWithTimeoutAsync<T>(
    Func<CancellationToken, Task<T>> operation,
    TimeSpan timeout)
{
    using var cts = new CancellationTokenSource(timeout);
    try
    {
        return await operation(cts.Token);
    }
    // cts.IsCancellationRequested が true = このタイムアウト CTS がトリガー
    catch (OperationCanceledException) when (cts.IsCancellationRequested)
    {
        Console.WriteLine($"タイムアウト: {timeout.TotalSeconds}秒を超えました");
        return default;
    }
    // cts.IsCancellationRequested が false = 外部トークンによるキャンセル
    // → catch しない(上位で処理させる)
}

ExceptionDispatchInfo — スタックトレースを手動で保全する

ExceptionDispatchInfo は例外のスタックトレースを保全しながら後から再スローするためのクラスです。when フィルターと組み合わせたり、非同期境界を越えて例外を伝播させる場面で使います。

ExceptionDispatchInfo の使い方
using System.Runtime.ExceptionServices;

// ① 例外情報をキャプチャして後から再スロー
ExceptionDispatchInfo? captured = null;
try
{
    throw new InvalidOperationException("元の例外");
}
catch (InvalidOperationException ex)
{
    // スタックトレースを保全したまま例外情報をキャプチャ
    captured = ExceptionDispatchInfo.Capture(ex);
}

// 後から別のスコープで再スロー(スタックトレース保全)
if (captured != null)
{
    // 元のスタックトレース + 現在位置のマーカーが付く
    captured.Throw();  // 元のスタックトレースを保持して再スロー
}

// ② 複数タスクの例外を個別に再スローする例
static async Task ProcessWithPreservedStackAsync(List<Func<Task>> tasks)
{
    var exceptions = new List<ExceptionDispatchInfo>();

    foreach (var task in tasks)
    {
        try
        {
            await task();
        }
        catch (Exception ex)
        {
            exceptions.Add(ExceptionDispatchInfo.Capture(ex));
        }
    }

    if (exceptions.Count == 1)
    {
        exceptions[0].Throw();  // 単一例外: 元のスタックトレースで再スロー
    }
    else if (exceptions.Count > 1)
    {
        throw new AggregateException(exceptions.Select(e => e.SourceException));
    }
}
ExceptionDispatchInfo の使いどころ
通常の throw(引数なし)で十分なケースがほとんどです。ExceptionDispatchInfo が必要になる典型的な場面は① 例外オブジェクトを変数に保存してから別スコープで再スロー、② try-catch ブロック外で例外を再スローしたい、③ 複数の例外を収集して順番に再スローしたい場合です。when(LogException(ex)) パターンと組み合わせることで完全なスタックトレースのログを保全しながら例外を伝播できます。

when を使う・使わない設計ガイドライン

場面 推奨アプローチ 理由
同じ例外型を複数の条件で分岐 when を使う 条件が宣言部に明示されてコードが整理される
デバッグで発生源を特定したい when を使う スタック巻き戻しが起きないため発生源が分かりやすい
例外をログだけして再スロー when (LogEx(ex)) を使う スタック保全のまま完全ログが取れる
HTTP ステータスコード・エラー番号で分岐 when を使う 同一例外型のプロパティで分岐できる
過渡エラーの再試行判定 when (IsTransient(ex))を使う 再試行対象かどうかをクリーンに表現
when 内でリソースを解放したい 使わない(finally / using で行う) when 式内での副作用は例外ハンドリング第2フェーズで実行されるため、解放漏れや予期しない挙動の原因になる
when 内で副作用のある処理 原則避ける(ログ用の false 返しは例外) 条件評価中の副作用は予測困難
単純な例外型による分岐 通常の catch でよい when の利点が活きない

よくある質問

Qwhen 句の条件内で例外が発生するとどうなりますか?
Awhen 条件の評価中に例外が発生した場合、その例外は無視され、フィルターが false を返したのと同じ動作をします(例外は次の catch へ伝播します)。条件評価中の例外はオリジナルの例外に置き換わらず、静かに無視される点に注意してください。
Qfinally ブロックは when フィルターが false のとき実行されますか?
Awhen フィルターが false の場合、その catch ブロックには入りません。したがって catch ブロック内の処理は実行されません。ただし finally ブロックは実行されます — 例外がそのスコープを抜ける際、finally は常に実行されるためです(try-catch-finally の finally は例外が catch されなくても動きます)。リソース解放は catch ではなく using / finally に委ねてください。
Qwhen 句のパフォーマンスはどうですか?
Awhen フィルターの評価は、catch ブロックに入る前のスタック巻き戻し前に行われます。フィルターが false を返した場合、catch ブロックへの分岐とその後の処理がスキップされるため、catch 内で if-rethrow するよりも若干効率的です。ただし例外のスロー自体のコストと比較すると誤差レベルであり、パフォーマンスよりもコードの可読性・デバッグしやすさを優先して使いましょう。
Qwhen 句と switch 式はどう使い分けますか?
A例外型と条件の組み合わせで処理を分ける場合は catch + when が適しています。例外を catch したあとの内部分岐(同一 catch 内で処理を切り替える)には switch 式が便利です。たとえば catch (ApiException ex) のあとで ex.StatusCode switch { 429 => ..., 503 => ..., _ => ... } のように書けます。

まとめ

機能・パターン ポイント
基本構文 catch (ExType ex) when (条件)。false なら次の catch へ
スタック非巻き戻し when(false) ではスタックが巻き戻されない → デバッガーが発生源で止まる
ログ専用フィルター when (LogException(ex))。常に false を返してログだけ記録
パターンマッチング when (ex is MyEx { Code: 404 }) でプロパティを直接参照
SqlException.Number エラー番号で DB エラーを分岐。再試行・制約違反・タイムアウトを識別
過渡エラー再試行 when (IsTransient(ex)) で再試行対象を明確に分離
CancellationToken userCts + timeoutCts + CreateLinkedTokenSource で別管理。when (timeoutCts.IsCancellationRequested) で判別
ExceptionDispatchInfo スタックトレース保全のまま後から再スロー
設計原則 when 内で副作用は最小限に。リソース解放は finally / using に委ねる

try-catch の基礎・throw vs throw ex・カスタム例外・async/await での例外処理は例外処理完全ガイドを参照してください。