🦆

【Dart】エラーハンドリング/インドカレーショップでインド人のお客様がルピーで決済してしまった場合にエラーを出す

に公開

こんにちは〜非エンジニア道上です!
https://zenn.dev/yochi/articles/4deca630e30094

Dartを学んでいる方にとって、「例外処理」や「エラーハンドリング」は避けて通れない重要なテーマです。
今回は、カレーショップの決済処理を題材に、異なるエラーケースとともに、実践的なエラーハンドリングの書き方を丁寧に解説していきます。

シナリオ
あなたはカリーショップの店長。日々、さまざまなお客さんが訪れてはカレーを注文していきます。ところが、支払いには様々な“クセ強”な問題が!
-お金が足りない!
-支払い額が文字列!?
-まさかのルピー支払い!?
-そんなトラブルをすべてDartのコードで処理していきましょう!

ケース 条件 例外クラスの定義
1 支払いが文字列 FormatException
2 支払いが null MissingPaymentFieldException
3 負の支払い PaymentValueException
4 小数点が多すぎる -
5 0円支払い PaymentValueException
6 支払い不足 InsufficientPaymentException
7 外貨("ルピー"など)支払い FormatException または CurrencyMismatchException
8 ブール型での支払い FormatException
9 配列・マップ型 FormatException
10 非Mapデータ全般(String等) InvalidPaymentTypeException
11 通貨オブジェクト(Currency)で支払い NoSuchMethodError

コアコード

支払い処理の本体を CurryShop クラスに定義。

class CurryShop {
  final List<String> menu = ['ビーフカレー', 'チキンカレー', '野菜カレー'];
  double _sales = 0;

  void processPayment(dynamic data) {
    if (data == null) throw ArgumentError('データがnullです。');
    if (data is! Map<String, dynamic>) {
      throw InvalidPaymentTypeException('Map<String, dynamic> 形式ではありません。');
    }

    final item = data['item'];
    final price = data['price'];
    final paid = data['paid'];
    final currency = data['currency'] ?? 'JPY';
    final name = data['name'] ?? 'お客さま';

    print('👤 $name が "$item" を注文しました(支払い: $paid)');
    if (paid is Currency && paid.code == 'INR') {
      print('🇮🇳 $name はルピーで支払いました。');
      print('システムエラー');
      // クラッシュさせる:paid.amount に toDouble() を強制(定義してない)
      dynamic badValue = paid;
      double forcedValue =
          (badValue as Currency).amount
              .toDouble(); // 💥 NoSuchMethodErrorでクラッシュ
    }

    if (!menu.contains(item)) {
      throw ArgumentError('不正なメニュー: $item');
    }

    if (price == null || paid == null) {
      throw MissingPaymentFieldException('金額または支払いが不足しています。');
    }

    double parsedPrice, parsedPaid;
    try {
      parsedPrice =
          price is num ? price.toDouble() : double.parse(price.toString());
      parsedPaid =
          paid is num ? paid.toDouble() : double.parse(paid.toString());
    } catch (_) {
      throw FormatException('金額のフォーマットが正しくありません。');
    }

    if (parsedPaid.isNaN || parsedPrice.isNaN || parsedPaid.isInfinite) {
      throw PaymentValueException('NaNまたはInfinityは許可されません。');
    }

    if (parsedPaid < 0 || parsedPrice < 0) {
      throw PaymentValueException('負の金額は無効です。');
    }

    if (currency != 'JPY') {
      throw CurrencyMismatchException('外貨 "$currency" は利用できません。');
    }

    if (parsedPaid < parsedPrice) {
      throw InsufficientPaymentException(
        '支払いが足りません。必要: $parsedPrice 円、受取: $parsedPaid 円',
      );
    }

    final change = parsedPaid - parsedPrice;
    _sales += parsedPrice;
    print('✅ 「$item」購入完了。おつり: ${change.toStringAsFixed(2)} 円');
  }

  double get totalSales => _sales;
}

全体コード

全体コード
import 'dart:developer';

class Currency {
  final String code;
  final double amount;
  Currency(this.code, this.amount);

  
  String toString() => '$code $amount';
}

void main() {
  final shop = CurryShop();

  final paymentList = [
    {'item': 'ビーフカレー', 'price': 700, 'paid': 700}, // ✅正常
    {'item': 'チキンカレー', 'price': 600, 'paid': '700'}, // 1. 文字列型の金額
    {'item': '野菜カレー', 'price': 500, 'paid': null}, // 2. null支払い
    {'item': 'ビーフカレー', 'price': 700, 'paid': -100}, // 3. 負の支払い
    {'item': 'ビーフカレー', 'price': 700, 'paid': 1000.987}, // 4. 小数点以下が細かすぎる
    {'item': 'チキンカレー', 'price': 600, 'paid': 0}, // 5. 0円支払い
    {'item': '野菜カレー', 'price': 500, 'paid': 400}, // 6. 不足金額
    {'item': 'ビーフカレー', 'price': 700, 'paid': 'ルピー'}, // 7. 外貨支払い(文字)
    {'item': 'チキンカレー', 'price': 600, 'paid': true}, // 8. ブール型
    {
      'item': '野菜カレー',
      'price': 500,
      'paid': [1000],
    }, // 9. 配列型
    {
      'item': 'ビーフカレー',
      'price': 700,
      'paid': {'yen': 700},
    }, // 10. マップ型
    {'item': 'チキンカレー', 'price': 600, 'paid': 600000000000}, // 11. 異常に大きな金額
    {
      'item': '野菜カレー',
      'price': 500,
      'paid': 600,
      'currency': 'INR',
    }, // 12. 通貨コード付き外貨
    {'item': 'ビーフカレー', 'price': 700, 'paid': () => 700}, // 13. 関数型
    {'item': 'チキンカレー', 'price': 600}, // 14. paidキー欠如
    {'item': '野菜カレー', 'price': '五百', 'paid': 500}, // 15. priceが日本語
    {'item': 'ビーフカレー', 'price': 700, 'paid': double.nan}, // 16. NaN支払い
    {'item': 'チキンカレー', 'price': 600, 'paid': double.infinity}, // 17. 無限大支払い
    'invalid_entry', // 18. 完全に無効な形式(String)
    null, // 19. nullデータ
    {'item': '野菜カレー', 'price': 500, 'paid': 500}, // ✅正常
    {
      'item': 'ビーフカレー',
      'price': 700,
      'paid': Currency('INR', 700), // インド人がルピーでお会計
      'name': 'インドからのお客さま',
    },
  ];

  for (var entry in paymentList) {
    bool success = false;
    try {
      shop.processPayment(entry);
      success = true;
    } on InvalidPaymentTypeException catch (e) {
      print('🛑 型が不正です: $e');
    } on MissingPaymentFieldException catch (e) {
      print('🛑 支払い情報が不足しています: $e');
    } on CurrencyMismatchException catch (e) {
      print('🛑 外貨での支払いはできません: $e');
    } on InsufficientPaymentException catch (e) {
      print('🛑 支払い金額が不足しています: $e');
    } on PaymentValueException catch (e) {
      print('🛑 金額が不正です: $e');
    } on FormatException catch (e) {
      print('🛑 フォーマットエラー: $e');
    } on ArgumentError catch (e) {
      print('🛑 引数エラー: $e');
    } on Exception catch (e, stack) {
      print('🛑 未知のエラー: $e');
      log('Unknown error', error: e, stackTrace: stack);
    }

    if (success) {
      print('🧾 支払い処理完了。\n');
    } else {
      print('❌ 支払いは完了できませんでした。\n');
    }
  }

  print('💰 本日の売上: ${shop.totalSales} 円');
}

// ==============================
// エラークラス定義
// ==============================

class InvalidPaymentTypeException implements Exception {
  final String message;
  InvalidPaymentTypeException(this.message);
  
  String toString() => 'InvalidPaymentTypeException: $message';
}

class MissingPaymentFieldException implements Exception {
  final String message;
  MissingPaymentFieldException(this.message);
  
  String toString() => 'MissingPaymentFieldException: $message';
}

class CurrencyMismatchException implements Exception {
  final String message;
  CurrencyMismatchException(this.message);
  
  String toString() => 'CurrencyMismatchException: $message';
}

class InsufficientPaymentException implements Exception {
  final String message;
  InsufficientPaymentException(this.message);
  
  String toString() => 'InsufficientPaymentException: $message';
}

class PaymentValueException implements Exception {
  final String message;
  PaymentValueException(this.message);
  
  String toString() => 'PaymentValueException: $message';
}

// ==============================
// 店舗ロジック
// ==============================

class CurryShop {
  final List<String> menu = ['ビーフカレー', 'チキンカレー', '野菜カレー'];
  double _sales = 0;

  void processPayment(dynamic data) {
    if (data == null) throw ArgumentError('データがnullです。');
    if (data is! Map<String, dynamic>) {
      throw InvalidPaymentTypeException('Map<String, dynamic> 形式ではありません。');
    }

    final item = data['item'];
    final price = data['price'];
    final paid = data['paid'];
    final currency = data['currency'] ?? 'JPY';
    final name = data['name'] ?? 'お客さま';

    print('👤 $name が "$item" を注文しました(支払い: $paid)');
    if (paid is Currency && paid.code == 'INR') {
      print('🇮🇳 $name はルピーで支払いました。');
      print('システムエラー');
      // クラッシュさせる:paid.amount に toDouble() を強制(定義してない)
      dynamic badValue = paid;
      double forcedValue =
          (badValue as Currency).amount
              .toDouble(); // 💥 NoSuchMethodErrorでクラッシュ
    }

    if (!menu.contains(item)) {
      throw ArgumentError('不正なメニュー: $item');
    }

    if (price == null || paid == null) {
      throw MissingPaymentFieldException('金額または支払いが不足しています。');
    }

    double parsedPrice, parsedPaid;
    try {
      parsedPrice =
          price is num ? price.toDouble() : double.parse(price.toString());
      parsedPaid =
          paid is num ? paid.toDouble() : double.parse(paid.toString());
    } catch (_) {
      throw FormatException('金額のフォーマットが正しくありません。');
    }

    if (parsedPaid.isNaN || parsedPrice.isNaN || parsedPaid.isInfinite) {
      throw PaymentValueException('NaNまたはInfinityは許可されません。');
    }

    if (parsedPaid < 0 || parsedPrice < 0) {
      throw PaymentValueException('負の金額は無効です。');
    }

    if (currency != 'JPY') {
      throw CurrencyMismatchException('外貨 "$currency" は利用できません。');
    }

    if (parsedPaid < parsedPrice) {
      throw InsufficientPaymentException(
        '支払いが足りません。必要: $parsedPrice 円、受取: $parsedPaid 円',
      );
    }

    final change = parsedPaid - parsedPrice;
    _sales += parsedPrice;
    print('✅ 「$item」購入完了。おつり: ${change.toStringAsFixed(2)} 円');
  }

  double get totalSales => _sales;
}

インド人の支払いでクラッシュ!

{
  'item': 'ビーフカレー',
  'price': 700,
  'paid': Currency('INR', 700),
  'name': 'インドからのお客さま',
}

この支払いは .toDouble() メソッドが Currency に存在しないため、NoSuchMethodError によりDart VMがクラッシュします。
💥 "Unhandled exception: NoSuchMethodError: Class 'Currency' has no instance method 'toDouble'."
これは try-catch では拾われない Dart のランタイムエラー です

学びのポイントまとめ

-Dartのtry-catch-finally構文で多様なエラーを安全に処理できる
-例外は具体的なクラスでハンドリングすることで保守性が向上する
-Error(例:NoSuchMethodError)は Exception と異なり基本的にクラッシュする
-外貨や不正入力などの想定外を想定“内”にすることで、システムの堅牢性がUP

Discussion