try-catchの罠!範囲を絞らないとバグが見つからない理由と正しい書き方

「エラーが起きそうだから、とりあえず全部try-catchで囲んでおけば安心」そんな風に考えていませんか?実は、これがバグを見つけにくくする最大の原因です。広すぎるtry-catch範囲は、エラーを隠蔽し、デバッグを困難にします。

この記事では、なぜ範囲を絞るべきなのか、そして適切なthrowの使い方まで、実際のコード例で解説します。

目次

「とりあえずtry-catch」が生む問題

多くの開発者が陥る、try-catchの典型的な問題パターンを見てみましょう。

広すぎる範囲の弊害

// ❌ 悪い例:範囲が広すぎる
async function processUserData(userData) {
  try {
    // 100行以上のコードがここに...
    const user = validateUser(userData)
    const profile = await fetchProfile(user.id)
    const preferences = calculatePreferences(profile)
    const recommendations = await generateRecommendations(preferences)
    const finalData = formatData(recommendations)
    await saveToDatabase(finalData)
    return finalData
  } catch (error) {
    console.log('何かエラーが起きました:', error.message)
    return null
  }
}

この書き方の問題点:

  • エラーの発生箇所が特定できない
    ・どの処理でエラーが起きたかわからない
    ・デバッグに時間がかかる
  • 異なる種類のエラーが同じ処理される
    ・バリデーションエラーもDB接続エラーも同じ扱い
    ・適切な復旧処理ができない
  • 予期しないエラーも隠蔽される
    ・プログラムのバグまでキャッチしてしまう
    ・本来修正すべき問題が見過ごされる

エラーが隠蔽される典型例

// ❌ さらに悪い例:エラーを隠蔽
function calculateTotal(items) {
  try {
    let total = 0
    for (let item of items) {
      // typo: item.prie → item.price
      total += item.prie * item.quantity
    }
    return total
  } catch (error) {
    // エラーをキャッチしても何もしない
    return 0  // 無言で0を返す
  }
}

// 使用例
const items = [{price: 100, quantity: 2}, {price: 200, quantity: 1}]
const total = calculateTotal(items)
console.log(total) // 0が表示される(本来は400であるべき)

この例では、item.prieというtypoによりundefinedの計算が発生しますが、try-catchで隠蔽されてしまい、バグに気づけません。

Before/After:悪い書き方vs良い書き方

同じ機能を実装する場合の比較を見てみましょう。

❌ Before:広範囲なtry-catch

// 悪い例:何でもかんでもtry-catch
async function createUser(userData) {
  try {
    // バリデーション
    if (!userData.email) throw new Error('Email required')
    if (!userData.name) throw new Error('Name required')
    
    // 外部API呼び出し
    const emailCheck = await checkEmailExists(userData.email)
    
    // データ加工
    const processedData = {
      email: userData.email.toLowerCase(),
      name: userData.name.trim(),
      createdAt: new Date()
    }
    
    // DB保存
    const result = await database.users.create(processedData)
    
    // 通知送信
    await sendWelcomeEmail(result.email)
    
    return result
  } catch (error) {
    // すべてのエラーを同じように処理
    console.error('ユーザー作成エラー:', error.message)
    throw new Error('ユーザー作成に失敗しました')
  }
}

✅ After:範囲を絞ったtry-catch

// 良い例:処理ごとに適切にtry-catchを配置
async function createUser(userData) {
  // バリデーション(try-catch不要:同期処理で予期できるエラー)
  if (!userData.email) {
    throw new ValidationError('Email required')
  }
  if (!userData.name) {
    throw new ValidationError('Name required')
  }
  
  // 外部API呼び出し(ネットワークエラーのみキャッチ)
  let emailExists
  try {
    emailExists = await checkEmailExists(userData.email)
  } catch (error) {
    console.error('Email check failed:', error.message)
    throw new ExternalServiceError('メール確認サービスが利用できません')
  }
  
  if (emailExists) {
    throw new ValidationError('このメールアドレスは既に使用されています')
  }
  
  // データ加工(try-catch不要:同期処理)
  const processedData = {
    email: userData.email.toLowerCase(),
    name: userData.name.trim(),
    createdAt: new Date()
  }
  
  // DB保存(DB接続エラーのみキャッチ)
  let user
  try {
    user = await database.users.create(processedData)
  } catch (error) {
    console.error('Database error:', error.message)
    throw new DatabaseError('ユーザー情報の保存に失敗しました')
  }
  
  // 通知送信(失敗しても処理を続行)
  try {
    await sendWelcomeEmail(user.email)
  } catch (error) {
    console.warn('Welcome email failed:', error.message)
    // エラーログは残すが、処理は続行
  }
  
  return user
}

デバッグしやすさの違い

Before(悪い例)の場合:

  • エラーログ: 「ユーザー作成エラー: Network timeout」
  • 問題: どこでネットワークタイムアウトが起きたかわからない
  • デバッグ: 全体を調査する必要がある

After(良い例)の場合:

  • エラーログ: 「Email check failed: Network timeout」
  • 問題: メール確認APIでタイムアウトが発生
  • デバッグ: 該当部分のみ調査すればよい

try-catch最適化の4原則

効果的なエラーハンドリングのための基本原則を解説します。

① 範囲を最小限に絞る

// ✅ 良い例:エラーが起きる可能性のある処理のみ
async function processFile(filename) {
  // ファイル読み込みのみtry-catch
  let fileContent
  try {
    fileContent = await fs.readFile(filename, 'utf8')
  } catch (error) {
    throw new FileError(`ファイル読み込み失敗: ${filename}`)
  }
  
  // 同期処理はtry-catch不要
  const lines = fileContent.split('\n')
  const filteredLines = lines.filter(line => line.trim() !== '')
  
  // 別の非同期処理は別のtry-catch
  try {
    await processLines(filteredLines)
  } catch (error) {
    throw new ProcessingError('行の処理中にエラーが発生')
  }
}

② エラーの種類別に処理を分ける

// ✅ エラータイプ別の処理
async function fetchUserData(userId) {
  try {
    const response = await api.get(`/users/${userId}`)
    return response.data
  } catch (error) {
    // HTTPステータスコードで分岐
    if (error.response?.status === 404) {
      throw new NotFoundError(`ユーザーID ${userId} は存在しません`)
    } else if (error.response?.status === 403) {
      throw new AuthorizationError('このユーザー情報にアクセスする権限がありません')
    } else if (error.response?.status >= 500) {
      throw new ServerError('サーバーエラーが発生しました。しばらく待ってから再試行してください')
    } else if (error.code === 'NETWORK_ERROR') {
      throw new NetworkError('ネットワーク接続を確認してください')
    } else {
      // 予期しないエラーは詳細情報と共に再throw
      console.error('Unexpected API error:', error)
      throw new APIError(`API呼び出しエラー: ${error.message}`)
    }
  }
}

③ 適切にthrowで再送出する

// ❌ 悪い例:エラーを握りつぶす
function parseData(jsonString) {
  try {
    return JSON.parse(jsonString)
  } catch (error) {
    console.log('Parse error')
    return null  // 無言でnullを返す(危険)
  }
}

// ✅ 良い例:適切にthrowで上位に通知
function parseData(jsonString) {
  try {
    return JSON.parse(jsonString)
  } catch (error) {
    console.error('JSON parse failed:', error.message)
    // より詳細なエラー情報と共に再throw
    throw new ParseError(`無効なJSON形式です: ${error.message}`)
  }
}

// ✅ 回復可能な場合のみデフォルト値を返す
function parseDataWithDefault(jsonString, defaultValue = {}) {
  try {
    return JSON.parse(jsonString)
  } catch (error) {
    console.warn('JSON parse failed, using default:', error.message)
    return defaultValue  // 明示的にデフォルト値を返す
  }
}

④ ログ出力を適切に行う

// ✅ 適切なログレベルと情報
async function uploadFile(file, userId) {
  try {
    const result = await cloudStorage.upload(file)
    console.info('File uploaded successfully:', {
      userId,
      filename: file.name,
      fileId: result.id
    })
    return result
  } catch (error) {
    // エラーの詳細情報をログに記録
    console.error('File upload failed:', {
      userId,
      filename: file.name,
      error: error.message,
      stack: error.stack,
      timestamp: new Date().toISOString()
    })
    
    // ユーザー向けにはわかりやすいメッセージで再throw
    throw new UploadError('ファイルのアップロードに失敗しました。ファイルサイズや形式を確認してください。')
  }
}

throw忘れで起きる隠蔽問題

catchしたエラーを適切にthrowしないことで起きる深刻な問題を見てみましょう。

catchしたけど何もしない危険性

// ❌ 危険な例:エラーを隠蔽
async function saveUserSettings(userId, settings) {
  try {
    await database.updateUserSettings(userId, settings)
  } catch (error) {
    console.log('設定保存エラー')
    // throwしない = エラーが隠蔽される
  }
  
  // この処理は実行される(問題!)
  console.log('設定を保存しました')
  return { success: true }  // 嘘の成功レスポンス
}

// 使用側では成功したと思ってしまう
const result = await saveUserSettings(123, {theme: 'dark'})
if (result.success) {
  showMessage('設定を保存しました')  // 実際は保存されていない
}

上位層にエラーを伝える重要性

// ✅ 正しい例:エラーを適切に上位に通知
async function saveUserSettings(userId, settings) {
  try {
    await database.updateUserSettings(userId, settings)
    console.info('User settings saved:', { userId, settings })
    return { success: true }
  } catch (error) {
    console.error('Failed to save user settings:', {
      userId,
      settings,
      error: error.message
    })
    // 必ずthrowして上位にエラーを通知
    throw new SettingsError('設定の保存に失敗しました')
  }
}

// 使用側で適切にエラーハンドリング
try {
  const result = await saveUserSettings(123, {theme: 'dark'})
  showMessage('設定を保存しました')
} catch (error) {
  if (error instanceof SettingsError) {
    showError('設定の保存に失敗しました。再試行してください。')
  } else {
    showError('予期しないエラーが発生しました。')
  }
}

throw vs return の使い分け

// ケース1: 回復不可能なエラー → throw
async function connectToDatabase(config) {
  try {
    const connection = await createConnection(config)
    return connection
  } catch (error) {
    console.error('Database connection failed:', error.message)
    // DB接続失敗は回復不可能 → throw
    throw new DatabaseConnectionError('データベースに接続できません')
  }
}

// ケース2: 回復可能またはオプション処理 → return
async function fetchUserAvatar(userId) {
  try {
    const avatar = await api.getUserAvatar(userId)
    return avatar
  } catch (error) {
    console.warn('Avatar fetch failed, using default:', error.message)
    // アバター取得失敗は回復可能 → デフォルト値をreturn
    return '/images/default-avatar.png'
  }
}

// ケース3: 部分的な成功 → 結果オブジェクトでreturn
async function sendNotifications(userIds, message) {
  const results = []
  
  for (const userId of userIds) {
    try {
      await sendNotification(userId, message)
      results.push({ userId, success: true })
    } catch (error) {
      console.error(`Notification failed for user ${userId}:`, error.message)
      // 個別の失敗は記録するが処理は続行
      results.push({ userId, success: false, error: error.message })
    }
  }
  
  return results  // 成功・失敗の詳細を返す
}

言語別実践例

他の言語でも同様の原則が適用できます。

Python例

# ✅ Pythonでの適切なtry-catch
import json
import requests
from typing import Dict, Any

class APIError(Exception):
    pass

class ValidationError(Exception):
    pass

def fetch_and_process_data(user_id: int) -> Dict[str, Any]:
    # バリデーション(try不要)
    if not isinstance(user_id, int) or user_id <= 0:
        raise ValidationError(f"無効なユーザーID: {user_id}")
    
    # API呼び出し(ネットワークエラーのみキャッチ)
    try:
        response = requests.get(f"/api/users/{user_id}", timeout=10)
        response.raise_for_status()
    except requests.exceptions.RequestException as e:
        print(f"API request failed: {e}")
        raise APIError(f"ユーザー情報の取得に失敗しました: {e}")
    
    # JSON解析(パースエラーのみキャッチ)
    try:
        data = response.json()
    except json.JSONDecodeError as e:
        print(f"JSON decode failed: {e}")
        raise APIError("サーバーからの応答が無効です")
    
    # データ処理(try不要:同期処理)
    processed_data = {
        "id": data.get("id"),
        "name": data.get("name", "").strip(),
        "email": data.get("email", "").lower()
    }
    
    return processed_data

Java例

// ✅ Javaでの適切な例外処理
public class UserService {
    
    public User createUser(UserData userData) throws UserCreationException {
        // バリデーション(例外不要)
        if (userData.getEmail() == null || userData.getEmail().isEmpty()) {
            throw new IllegalArgumentException("メールアドレスは必須です");
        }
        
        // DB操作(SQL例外のみキャッチ)
        User user;
        try {
            user = userRepository.save(userData);
        } catch (SQLException e) {
            logger.error("Database error during user creation", e);
            throw new UserCreationException("ユーザー情報の保存に失敗しました", e);
        }
        
        // 通知送信(失敗しても処理続行)
        try {
            emailService.sendWelcomeEmail(user.getEmail());
        } catch (EmailException e) {
            logger.warn("Welcome email failed for user: " + user.getId(), e);
            // エラーログのみ、処理は続行
        }
        
        return user;
    }
}

エラーハンドリングをさらに極める関連記事

try-catchの最適化を習得したら、AI開発支援ツールやコード品質管理ツールも活用してより堅牢なコードを書きましょう:

AI開発支援・コード管理

開発効率化・可視化ツール

まとめ:エラーハンドリングは「捕まえる」だけでなく「伝える」

try-catchは「何かにトライして、エラーをキャッチし、適切にスローする」の3段階で考えることが重要です。範囲を絞り、エラーの種類を分け、必要に応じて上位に再送出することで、デバッグしやすく保守性の高いコードになります。

今日から始められるアクション:

  • 既存のtry-catchブロックの範囲を見直す
  • エラーログに詳細情報を追加する
  • throwしていない箇所を特定・修正する
  • エラータイプ別の処理分岐を実装する

「とりあえずtry-catch」から卒業して、意図的で効果的なエラーハンドリングを実践しましょう。

try-catch最適化 よくある質問

すべての非同期処理にtry-catchは必要ですか?

いいえ、エラーが起きる可能性のある処理のみに配置すべきです。確実に成功する処理(例:配列のmap操作など)や、上位でまとめてキャッチする設計の場合は不要です。async/awaitを使う際は、Promiseのrejectを適切に処理できる箇所にのみ設置しましょう。

catchでログを出した後、必ずthrowする必要がありますか?

回復不可能なエラーの場合は必ずthrowすべきです。ただし、オプション機能の失敗(アバター画像の取得失敗等)やデフォルト値で代用できる場合は、ログ出力後にデフォルト値をreturnする選択肢もあります。重要なのは呼び出し元が適切に状況を判断できることです。

複数の処理をまとめてtry-catchで囲むのは常に悪いですか?

すべて同じ種類のエラー(例:同一APIへの複数リクエスト)で、同じ処理をする場合は問題ありません。ただし、異なる種類のエラーが混在する場合は分離すべきです。また、どの処理でエラーが起きたかログで識別できるようにしてください。

カスタムエラークラスを作る基準はありますか?

呼び出し元で異なる処理が必要な場合は作成を推奨します。例:ValidationError(ユーザーに入力修正を促す)、NetworkError(再試行を提案)、AuthError(ログイン画面にリダイレクト)など。単純なログ出力のみの場合は、既存のErrorクラスにメッセージを追加するだけで十分です。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次