【実務・中級編】Dart 3のパターンマッチングで「Result型」を実装し、例外処理を型安全にする – Dart コア文法・オブジェクト指向・Null安全解析バイブル

Dart 3パターンマッチングが変えるエラーハンドリング:`try-catch`を駆逐する型安全な`Result型`の設計

テックリードの私だ。コードレビューの現場で、未だにこんなコードを見かけるたびに私は深いため息をつく。

try {
final user = await api.fetchUser(id);
process(user);
} catch (e, st) {
// ログを出して…どうリカバリーするんだっけ?
logger.error(e, st);
}

言っておくが、例外(Exception)による制御フローの乗っ取りは、モダンなアプリケーション開発において「技術的負債の温床」でしかない。どこで何が投げられるのかシグネチャからは一切読み取れず、キャッチし忘れた瞬間にアプリはクラッシュする。

Dart 3で導入された `sealed class` と `pattern matching`(パターンマッチング)、そして `record` 型。これらを組み合わせることで、私たちは「エラーを例外として隠蔽せず、ファーストクラスの値として扱う」堅牢な世界を手に入れた。

今回は、実務のフロントエンド開発やAPI連携において、バグの起しようがない鉄壁の`Result型`アーキテクトニクスを授けよう。

—

なぜ `try-catch` は実務で破綻するのか?

例外機構の本質は「想定外の異常系」のためのものだ。ネットワークの切断、APIの404、バリデーションエラー――これらはビジネスロジック上「起こり得る正常な分岐」にすぎない。

これを `try-catch` で処理すると、以下の致命的な問題が発生する。

1. 型情報の喪失: `catch (e)` の `e` は常に `Object?` であり、静的型安全性が完全に破壊される。
2. 呼び出し元での隠蔽: 関数シグネチャを見ただけでは、その関数がどんなエラーを返すのかコンパイラが教えてくれない。
3. 網羅性チェックの欠如: 新しいエラー種別を追加しても、コンパイラは `catch` 側の修正漏れを検知できない。

Dart 3の網羅的パターンマッチングは、これらの問題を一刀両断する。

—

実装:プロダクションクオリティの `Result`

まずは、コンパイル時最適化とゼロコスト抽象化を意識した `Result` 型のコア実装を見てほしい。余計なボイラープレートは一切削ぎ落としている。

import ‘package:meta/meta.dart’;

/// 成功と失敗を型安全に表現する封印された(sealed)代数的データ型
@immutable
sealed class Result {
const Result();

/// 成功値を取り出す、またはデフォルト値を返す
T getOrElse(T defaultValue) => switch (this) {
Success(value: final v) => v,
Failure() => defaultValue,
};
}

/// 成功を表すバリアント
final class Success extends Result {
const Success(this.value);
final T value;

@override
bool operator ==(Object other) =>
identical(this, other) || other is Success && other.value == value;

@override
int get hashCode => value.hashCode;
}

/// 失敗を表すバリアント(エラー型Eを強制)
final class Failure extends Result {
const Failure(this.error);
final E error;

@override
bool operator ==(Object other) =>
identical(this, other) || other is Failure && other.error == error;

@override
int get hashCode => error.hashCode;
}

チーフアーキテクトの解説:なぜこの構造なのか?

  • `sealed class`: 同一ファイル内でのみサブクラス化が許可される。これにより、Dart VMとAOTコンパイラ(dart2native)は、スイッチ文での網羅性(Exhaustiveness)を完全に静的解析できる。つまり、将来バリアントが増えたとき、対応漏れは100%コンパイルエラーになる。
  • `@meta` と イミュータビリティ: 状態が途中で書き換わるバグを防ぐため、値はすべてイミュータブル。

—

実践:APIクライアントへの適用とパターンマッチング

では、この `Result` を実際の非同期API連携でどう使うのか。
ドメイン層のエラーを定義し、リポジトリ層からUI層に至るまでの美しいデータフローを見てみよう。

// 1. ドメイン固有のエラー定義(レコード型やsealedで詳細を表現可能)
sealed class ApiError {
const ApiError();
}
class NetworkError extends ApiError { const NetworkError(); }
class ServerError extends ApiError { const ServerError(this.code); final int code; }
class UnauthorizedError extends ApiError { const UnauthorizedError(); }

// 2. リポジトリ層:例外を絶対に外に投げず、Result型として返す
Future> fetchUserProfile(String userId) async {
try {
// 外部通信の模擬
if (userId.isEmpty) {
return const Failure(NetworkError());
}
if (userId == ‘401’) {
return const Failure(UnauthorizedError());
}

// 成功時はSuccessで包む
return Success(UserProfile(id: userId, name: ‘Dart Wizard’));
} catch (e) {
// 予期せぬシステム例外はここでキャッチし、ドメインエラーにマッピング
return const Failure(ServerError(500));
}
}

class UserProfile {
const UserProfile({required this.id, required this.name});
final String id;
final String name;
}

UI・プレゼンテーション層でのスイッチ式パターンマッチング

ここからがDart 3の真骨頂だ。UIのレンダリングや状態管理(BLoC / Riverpodなど)で、`switch` 式を用いたパターンマッチングにより、安全かつ宣言的にUIを構築する。

String renderUserScreen(Result result) {
// Dart 3の switch 式による網羅的パターンマッチング
return switch (result) {
// 成功パターン
Success(value: final user) => ‘ようこそ、${user.name}さん!’,

// 失敗パターン(さらにエラーの種類で細かく分岐)
Failure(error: NetworkError()) => ‘ネットワーク接続を確認してください。’,
Failure(error: UnauthorizedError()) => ‘セッションが切れました。再ログインしてください。’,
Failure(error: ServerError(code: final c)) => ‘サーバーエラーが発生しました (Code: $c)’,
};
}

このコードの美しさは、「処理の漏れが絶対に起きない」という点にある。もし将来、`ApiError` に `MaintenanceError` を追加した場合、Dartのコンパイラは即座に赤い波線を出し、「すべてのケースが網羅されていません」と我々に警告を発する。

—

パフォーマンスとVMの最適化に関する知見

「こんなクラス階層を量産して、GC(ガベージコレクション)の負荷にならないのか?」
優秀なエンジニアならそう懸念するはずだ。Dart VMの内部構造を知る者として回答しよう。

1. アロケーションの最適化: `Success` や `Failure` はヒープにアロケートされるが、現代のDart VM(Generational GC)において、短命なオブジェクト(Young Generation)の生成・回収コストは極めて低い。
2. パターンマッチングのJIT/AOT最適化: Dart 3の `switch` 式は、単なる連続した `if-else` にコンパイルされるわけではない。コンパイラは効率的なジャンプテーブル(Jump Table)や型テストのインライン化を行い、実行時オーバーヘッドを最小限に抑え込む。

ただし、極限のパフォーマンスが求められるホットパス(毎フレーム呼ばれる描画ループなど)で頻繁にインスタンスを生成するのは避けるべきだ。通常のアプリケーション層、API境界、ドメインロジックにおいては、得られる「保守性」と「堅牢性」のメリットがコストを圧倒的に凌駕する。

—

コピペで使える!実務の現場で即導入する拡張テクニック

さらに実務で使いやすくするため、`Result` 型に `map` や `flatMap`(andThen)といった関数型プログラミングのユーティリティを生やしておこう。これがあると、非同期処理のチェインが劇的にエレガントになる。

extension ResultExt on Result {
bool get isSuccess => this is Success;
bool get isFailure => this is Failure;

/// 成功値の型を変換する (Functorのmap)
Result map(R Function(T t) transform) {
return switch (this) {
Success(value: final v) => Success(transform(v)),
Failure(error: final e) => Failure(e),
};
}

/// 次の非同期Result処理へ繋げる (Monadのbind / flatMap)
Result flatMap(Result Function(T t) transform) {
return switch (this) {
Success(value: final v) => transform(v),
Failure(error: final e) => Failure(e),
};
}
}

使用例:チェイン処理

void main() async {
final result = await fetchUserProfile(‘123’);

// mapを使ってUserProfileから名前だけを取り出すResultに変換
final nameResult = result.map((user) => user.name);

print(renderUserScreen(result));
}

—

まとめ:例外駆動開発からの脱却

例外駆動開発(Exception-Driven Development)は、コードの予測可能性を奪い、エラーハンドリングを「お祈りプログラミング」に変えてしまう。

Dart 3の `sealed class` とパターンマッチングを駆使した `Result型` は、エラーを「隠すべき異常」から「向き合うべきデータ」へと昇華させる。

明日のコードレビューで、誰かが `try-catch` で泥臭くエラーを処理しているのを見つけたら、このアーキテクチャを静かに提示してやるといい。
「おい、例外を投げるな。型で語れ」とね。

タイトルとURLをコピーしました