コードレビューをしていて、最も目を覆いたくなる瞬間の一つがこれだ。
// どこにでもある「動くが脆い」コード
final json = jsonDecode(response.body) as Map
final username = json[‘user’][‘name’] as String; // 💥 NullPointerException or TypeError
外部APIから飛んでくるJSONを、信じきってキャストする。スキーマが変わった瞬間、あるいは予期せぬ`null`が混入した瞬間にプロダクション環境でアプリがクラッシュする。TypeScriptであればZodやValibotを使う領域だが、モダンなDart(Dart 3以降)には、コンパイラと完全に統合された強力な「パターンマッチング」という最強の武器がある。
今回は、外部から受け取った未知のJSONを、Dart 3のパターンマッチングを用いてコンパイル時の安全性と極限のパフォーマンスを両立させながらバリデーションし、ドメインモデルへ昇華させる手法を解説しよう。
—
なぜ従来の `fromJson` は破綻するのか?
多くの開発者は、次のようなファクトリーコンストラクタを書く。
class User {
final String id;
final String name;
User.fromJson(Map
: id = json[‘id’] as String,
name = json[‘name’] as String;
}
このコードの何が問題か?
1. 実行時エラーの隠蔽: キャスト(`as String`)は、型が一致しなかった場合に容赦なく`TypeError`をスローする。
2. 構造の検証不足: `id`や`name`が欠損している場合や、想定外の型(例えば数値やネストされたオブジェクト)が来た場合のフォールバックがない。
3. 網羅性の欠如: エラーハンドリングが場当たり的になり、コードベース全体に不確実性が伝播する。
Dart 3のパターンマッチングと代数データ型(ADT)の概念を導入すれば、「不正なJSONをそもそもドメイン層に入れない」堅牢な境界(Boundary)を構築できる。
—
実装:パターンマッチングによる型安全JSONバリデーション
以下のプロダクションコードを見てほしい。外部APIからのレスポンスを安全に検証し、成功・失敗を型安全にハンドリングする完全な例だ。
import ‘dart:convert’;
// — 1. ドメインモデルの定義 —
sealed class ApiResult
const ApiResult();
}
class ApiSuccess
final T data;
const ApiSuccess(this.data);
}
class ApiError extends ApiResult
final String message;
const ApiError(this.message);
}
class UserProfile {
final String id;
final String name;
final int age;
const UserProfile({
required this.id,
required this.name,
required this.age,
});
// — 2. パターンマッチングを活用したバリデーションコンストラクタ —
static ApiResult
// Dart 3の構造パターンとガード節による検証
return switch (json) {
// 型チェックとマップ構造の分解を同時に行う
{‘id’: String id, ‘name’: String name, ‘age’: int age} =>
ApiSuccess(UserProfile(id: id, name: name, age: age)),
// 型の不一致やフィールドの欠損がある場合
Map
const ApiError(‘JSONのスキーマ構造が不正です(型またはキーのミスマッチ)’),
// Mapですらない場合(nullやプリミティブ値など)
_ =>
const ApiError(‘ルート要素がオブジェクトではありません’),
};
}
}
// — 3. 実行検証用コード —
void main() {
// パターンA: 正常なJSON
const validJsonString = ‘{“id”: “usr_001”, “name”: “Alice”, “age”: 30}’;
// パターンB: 型が不正なJSON(ageが文字列になっている)
const invalidTypeJsonString = ‘{“id”: “usr_002”, “name”: “Bob”, “age”: “30”}’;
// パターンC: キーが欠損しているJSON
const missingKeyJsonString = ‘{“id”: “usr_003”, “name”: “Charlie”}’;
for (final raw in [validJsonString, invalidTypeJsonString, missingKeyJsonString]) {
print(‘— 検証開始 —‘);
final decoded = jsonDecode(raw);
// パターンマッチング(switch式)による結果のハンドリング
// sealed classのおかげでコンパイラがすべてのケース(Success/Error)を網羅しているか強制する
final result = UserProfile.fromJson(decoded);
switch (result) {
case ApiSuccess(data: final user):
print(‘✅ 成功: ${user.name} (ID: ${user.id}, Age: ${user.age})’);
case ApiError(message: final msg):
print(‘❌ 失敗: $msg’);
}
}
}
—
コードの深層解説:なぜこの設計が優れているのか?
1. `switch` 式とオブジェクトパターンの統合
`switch (json)` の部分で、Dart 3のオブジェクトパターン(Object Pattern)とマップパターン(Map Pattern)が同時に機能している。
{‘id’: String id, ‘name’: String name, ‘age’: int age} => …
この一行は、Dart VMの内部で次のような検証を爆速で行っている。
- `json` が `Map` であることの確認。
- `’id’`, `’name’`, `’age’` というキーが存在することの確認。
- それぞれの値が、指定された型(`String`, `String`, `int`)に厳密に一致することの確認。
従来の `if` 文のネストや、手動での `is` キャストの嵐をエレガントに駆逐している。
2. `sealed` クラスによる「網羅性の強制(Exhaustiveness)」
結果を受け取る側(`main` 関数の後半)を見てほしい。`switch (result)` において、`ApiSuccess` と `ApiError` のハンドリングが網羅されているかをDartのコンパイラが静的解析している。もし将来的に `ApiLoading` などの新しい状態を追加した場合、このスイッチ文を書き換えない限りコンパイルエラーになる。バグの温床となる「予期せぬ状態の取りこぼし」を型システムが完全に根絶する。
—
パフォーマンス上の注意点とチーフアーキテクトからの助言
「パターンマッチングは便利だが、実行時コストが高いのではないか?」という懸念を持つ優秀なエンジニアもいるだろう。
Dart VMのAOT/JITコンパイラにおいて、Dart 3のパターンマッチングは高度に最適化されている。内部的には効率的な型ジャンプテーブルや構造検証コードにコンパイルされるため、手動で書く `as` キャストや冗長な `is` チェックと比較してパフォーマンスのペナルティは実質的にゼロである。
ただし、以下の実務上のアンチパターンには注意してほしい。
1. 巨大なJSONの全域パースを避ける
数十MBに及ぶ巨大なJSONツリー全体を一度にパターンマッチングしようとすると、メモリ効率が悪化する。API境界で必要なドメインモデルの単位にJSONを分割し、レイヤーごとにバリデーション境界を作るべきだ。
2. 動的なキー(Dynamic Keys)への過信
キー名が動的に変わる辞書データ(例: `Map
—
まとめ
外部データとの境界(Boundary)は、アプリケーションの信頼性を決める最重要防壁だ。
「動けばいいや」のキャストコードは、チームの成長とともに技術的負債へと変わり、深夜の障害対応というコストを会社に支払わせることになる。
Dart 3のパターンマッチングをマスターし、「不正なデータはコンパイルおよび境界の時点で一切通さない」という強固なマインドセットを君のプロダクションコードに導入してほしい。コードは美しくなり、バグは消滅し、コードレビューはもっと知的で生産的な議論へと進化するはずだ。