【実務・中級編】Null安全環境での『JSONパース』:freezed vs json_serializableの型安全性比較 – Dart コア文法・オブジェクト指向・Null安全解析バイブル

Dart Null安全時代のJSONパース:`freezed` vs `json_serializable`、堅牢なAPI連携を導く型安全性の比較

Webエンジニア諸君、APIからのレスポンスをDartで安全に扱うことに、どれほどの情熱を注いでいるだろうか。特に、Null安全が当たり前となった現代において、外部からの信頼できないデータをいかに型安全に、そして堅牢にアプリケーションに組み込むかは、プロジェクトの寿命を左右する重要な責務だ。

今日は、この「JSONパース」という、一見地味ながらも極めてクリティカルな領域に焦点を当てる。`freezed`と`json_serializable`、この二つの有力なライブラリを、Null安全というレンズを通して徹底的に比較し、君たちのプロダクションコードをより美しく、そしてバグの少ないものへと導くための知見を、惜しみなく提供しよう。

1. なぜJSONパースの型安全性にこだわる必要があるのか?

APIレスポンスのJSONは、我々がコントロールできない外部からのデータだ。JSONスキーマの変更、予期せぬ`null`値、あるいはデータ型の不整合。これらは、ランタイムエラーの温床となり、アプリケーションの信頼性を著しく低下させる。

Null安全 (`Sound Null Safety`) は、Dartコンパイラがコンパイル時に`null`による潜在的な問題を排除してくれる強力な味方だ。しかし、JSONパースの文脈では、このNull安全の恩恵を最大限に引き出すための「橋渡し」が必要となる。外部JSONの`null`とDartのNull安全型システムとの間の、厳密な整合性を確保しなければならないのだ。

1.1. 原始的なJSONパースの落とし穴

例えば、手作業でJSONをパースしようとすると、以下のようなコードに陥りがちだ。

// 警告:これはあくまで「落とし穴」の例です。
class User {
final String name;
final int? age; // ageはnullかもしれない

User({required this.name, this.age});

factory User.fromJson(Map json) {
// ここで型チェックやnullチェックを怠ると…
return User(
name: json[‘name’] as String, // ‘name’がnullまたはStringでない場合、実行時エラー!
age: json[‘age’] as int?, // ‘age’がintでない場合、実行時エラー!
);
}
}

// …
Map apiResponse = {‘name’: ‘Alice’, ‘age’: 30};
User user = User.fromJson(apiResponse);

// もし ‘name’ が null だったら?
// Map invalidResponse = {‘name’: null, ‘age’: 30};
// User invalidUser = User.fromJson(invalidResponse); // ここで実行時エラー!

このコードは、`as`演算子によるキャストで済ませているため、JSONの値が期待する型でなかったり、`null`であるべきでないフィールドが`null`だったりした場合、コンパイル時には検出できない実行時エラーを引き起こす。Null安全の思想に反する、極めて危険なコードだ。

1.2. 型安全性によるバグの早期発見

我々が目指すべきは、コンパイル時に型安全性を保証し、開発サイクルの早い段階でバグを発見できるような設計だ。JSONパースにおいても、この原則は例外ではない。

2. `freezed` vs `json_serializable`:Null安全における型安全性の比較

ここでは、JSONパースを型安全に、そして楽に行うための二大ライブラリ、`freezed`と`json_serializable`を、Null安全との親和性という観点から比較分析していく。

2.1. `json_serializable`:コード生成による堅牢性

`json_serializable`は、`build_runner`を利用して、DartコードからJSONシリアライゼーション/デシリアライゼーションのコードを自動生成するライブラリだ。その最大の特徴は、Dartの型システムと密接に連携し、コンパイル時の型チェックを強力にサポートする点にある。

`freezed`の利点:

  • Null安全への深い対応: Null許容型 (`Type?`) や非Null許容型 (`Type`) をDartの定義通りに扱う。JSONの`null`値はDartの`Type?`に、JSONに存在し`null`でない値はDartの`Type`に、それぞれマッピングされる。
  • コンパイル時の型エラー検出: JSONの構造とDartの型定義の不一致は、ビルド時にエラーとして検出される。
  • 既存のDartモデルへの適用: 比較的既存のDartクラス構造に適用しやすい。

`json_serializable`のコード例(Null安全対応):

まず、`pubspec.yaml`に依存関係を追加する。

dependencies:
flutter:
sdk: flutter
json_annotation: ^4.x.x # Use the latest version

dev_dependencies:
build_runner: ^2.x.x
json_serializable: ^6.x.x # Use the latest version

そして、モデルクラスを定義する。`@JsonSerializable()`アノテーションを付与し、`part`ディレクティブを使用する。

import ‘package:json_annotation/json_annotation.dart’;

part ‘user.g.dart’; // 生成されるファイル名

@JsonSerializable()
class User {
final String name; // 非Null許容型。JSONにnameがない、またはnullの場合はコンパイル/実行時エラー
final int? age; // Null許容型。JSONにageがない、またはnullでも許容される

User({required this.name, this.age});

factory User.fromJson(Map json) => _$UserFromJson(json);
Map toJson() => _$UserToJson(this);
}

ビルドを実行する。

flutter pub run build_runner build

これにより、`user.g.dart`ファイルが生成され、`User.fromJson`メソッドが自動的に実装される。

実行例と型安全性:

// 正常なレスポンス
Map validResponse = {‘name’: ‘Alice’, ‘age’: 30};
User user1 = User.fromJson(validResponse);
print(‘User 1: ${user1.name}, Age: ${user1.age}’); // 出力: User 1: Alice, Age: 30

// ‘age’ が null の場合 (Null許容型なのでOK)
Map responseWithNullAge = {‘name’: ‘Bob’, ‘age’: null};
User user2 = User.fromJson(responseWithNullAge);
print(‘User 2: ${user2.name}, Age: ${user2.age}’); // 出力: User 2: Bob, Age: null

// ‘age’ が欠損している場合 (Null許容型なのでOK)
Map responseWithoutAge = {‘name’: ‘Charlie’};
User user3 = User.fromJson(responseWithoutAge);
print(‘User 3: ${user3.name}, Age: ${user3.age}’); // 出力: User 3: Charlie, Age: null

// !!! コンパイル/実行時エラーを引き起こすケース !!!
// ‘name’ が null の場合(非Null許容型なのでエラー)
// Map responseWithNullName = {‘name’: null, ‘age’: 25};
// User invalidUser = User.fromJson(responseWithNullName); // ここでエラー!
// Error: Invalid value: Not a string. (from json_serializable’s internal checks)

// ‘name’ が欠損している場合(非Null許容型なのでエラー)
// Map responseWithoutName = {‘age’: 28};
// User anotherInvalidUser = User.fromJson(responseWithoutName); // ここでエラー!
// Error: Missing key: name. (from json_serializable’s internal checks)

`json_serializable`は、`name`のような非Null許容型フィールドがJSONに存在しない、あるいは`null`である場合に、実行時(またはビルド時、設定による)にエラーを発生させることで、Null安全の原則を守らせる。

2.2. `freezed`:不変性・代数的データ型による高次元の安全性

`freezed`は、Dartのクラスを、不変 (Immutable) かつ代数的データ型 (Algebraic Data Types – ADTs) のように扱うための強力なコード生成ライブラリだ。JSONパース機能も内蔵しており、その型安全性は`json_serializable`とは一線を画す。

`freezed`の利点:

  • 不変性 (Immutability): 生成されるクラスはデフォルトで不変となり、意図しない状態変更を防ぐ。
  • 代数的データ型 (ADTs) とパターンマッチング: `union` (sealed classes) や `freezed` class を用いて、複雑な状態遷移やレスポンスのバリエーションを安全に表現できる。
  • JSONパース機能: `freezed`自体がJSONパース機能を提供し、`json_serializable`のような追加の依存関係を不要にできる場合がある(ただし、`freezed`のJSONパースは`json_annotation`に依存しているため、実質的には同等の機能を提供する)。
  • Null安全との統合: DartのNull安全型システムを最大限に活用し、より厳密な型安全性を実現する。

`freezed`のコード例(Null安全対応):

`pubspec.yaml`に依存関係を追加する。

dependencies:
flutter:
sdk: flutter
freezed_annotation: ^2.x.x # Use the latest version
json_annotation: ^4.x.x # freezedのJSONパース機能はこれに依存

dev_dependencies:
build_runner: ^2.x.x
freezed: ^2.x.x # Use the latest version

モデルクラスを定義する。`@freezed`アノテーションと`@JsonSerializable()`アノテーションを組み合わせる。

import ‘package:freezed_annotation/freezed_annotation.dart’;

part ‘user.freezed.dart’; // freezedによる生成ファイル
part ‘user.g.dart’; // json_annotationによる生成ファイル

@freezed
class User with _$User {
// 非Null許容型。JSONにnameがない、またはnullの場合はパース時にエラー
const factory User({
required String name,
int? age, // Null許容型。JSONにageがない、またはnullでも許容される
}) = _User;

factory User.fromJson(Map json) => _$UserFromJson(json);
}

ビルドを実行する。

flutter pub run build_runner build

これにより、`user.freezed.dart`と`user.g.dart`が生成される。`user.g.dart`は`json_annotation`によって生成され、`user.freezed.dart`は`freezed`によって、`_User`クラスの不変性や`copyWith`メソッドなどが実装される。

実行例と型安全性:

`json_serializable`と同様の実行結果が得られる。

// 正常なレスポンス
Map validResponse = {‘name’: ‘Alice’, ‘age’: 30};
User user1 = User.fromJson(validResponse);
print(‘User 1: ${user1.name}, Age: ${user1.age}’); // 出力: User 1: Alice, Age: 30

// ‘age’ が null の場合 (Null許容型なのでOK)
Map responseWithNullAge = {‘name’: ‘Bob’, ‘age’: null};
User user2 = User.fromJson(responseWithNullAge);
print(‘User 2: ${name}, Age: ${user2.age}’); // 出力: User 2: Bob, Age: null

// ‘age’ が欠損している場合 (Null許容型なのでOK)
Map responseWithoutAge = {‘name’: ‘Charlie’};
User user3 = User.fromJson(responseWithoutAge);
print(‘User 3: ${user3.name}, Age: ${user3.age}’); // 出力: User 3: Charlie, Age: null

// !!! コンパイル/実行時エラーを引き起こすケース !!!
// ‘name’ が null の場合(非Null許容型なのでエラー)
// Map responseWithNullName = {‘name’: null, ‘age’: 25};
// User invalidUser = User.fromJson(responseWithNullName); // ここでエラー!
// Error: Invalid value: Not a string. (from json_serializable’s internal checks)

// ‘name’ が欠損している場合(非Null許容型なのでエラー)
// Map responseWithoutName = {‘age’: 28};
// User anotherInvalidUser = User.fromJson(responseWithoutName); // ここでエラー!
// Error: Missing key: name. (from json_serializable’s internal checks)

`freezed`も、`json_annotation`と連携することで、`json_serializable`と同様に、非Null許容型フィールドの欠損や`null`値を検出し、エラーを発生させる。

2.3. 型安全性におけるベストプラクティス

どちらのライブラリを選ぶにしても、Null安全を最大限に活かすためのベストプラクティスは共通している。

  • 非Null許容型 (`String`, `int`など) は、APIレスポンスで必ず存在し、nullでないと想定されるフィールドにのみ使用する。
  • Null許容型 (`String?`, `int?`など) は、APIレスポンスに存在しない可能性がある、あるいは`null`値が許容されるフィールドに使用する。
  • API仕様書を常に参照し、JSONの構造とDartの型定義を一致させる。
  • 生成されたコードを直接編集しない。

2.4. パフォーマンス上の注意点

コード生成ライブラリは、手書きのコードに比べて初期のコンパイル時間は増加する傾向にある。しかし、生成されるコードは一般的に最適化されており、ランタイムパフォーマンスへの影響は微々たるものであることが多い。むしろ、手書きによるバグの混入を防ぐことで、デバッグコストやランタイムエラーによるパフォーマンス低下を防ぐ方が、長期的なメリットは大きい。

3. 実務で使える「コピペで保守性の高い美しいプロダクションコード例」

ここからは、読者の皆さんがすぐにでもプロダクションコードに適用できるよう、より実践的なコード例を示す。

3.1. `freezed` を用いた、より複雑なレスポンスのパース

APIレスポンスには、ネストしたオブジェクトや、複数の状態を取りうるデータ構造が含まれることがよくある。`freezed`は、このようなケースを代数的データ型を用いてエレガントに表現できる。

シナリオ:

ユーザー情報と、そのユーザーの購読プラン(無料、有料、プレミアム)を表すレスポンス。

import ‘package:freezed_annotation/freezed_annotation.dart’;

part ‘api_response.freezed.dart’;
part ‘api_response.g.dart’;

// — Subscription Plan —
@freezed
sealed class SubscriptionPlan with _$SubscriptionPlan {
// 無料プラン: 追加情報なし
const factory SubscriptionPlan.free() = _FreeSubscriptionPlan;
// 有料プラン: 終了日時を持つ
const factory SubscriptionPlan.paid({required DateTime expiresAt}) = _PaidSubscriptionPlan;
// プレミアムプラン: 追加機能フラグを持つ
const factory SubscriptionPlan.premium({required bool hasExtraFeatures}) = _PremiumSubscriptionPlan;

factory SubscriptionPlan.fromJson(Map json) => _$SubscriptionPlanFromJson(json);
}

// — User —
@freezed
class User with _$User {
const factory User({
required String id,
required String name,
String? email, // Emailはnullかもしれない
required SubscriptionPlan plan, // SubscriptionPlanは必ず存在する
}) = _User;

factory User.fromJson(Map json) => _$UserFromJson(json);
}

// — API Response —
@freezed
class ApiResponse with _$ApiResponse {
const factory ApiResponse({
required User user,
required String message,
}) = _ApiResponse;

factory ApiResponse.fromJson(Map json) => _$ApiResponseFromJson(json);
}

// — JSON パースと利用例 —

void main() {
// 例1: 正常なプレミアムプランのレスポンス
final Map json1 = {
‘user’: {
‘id’: ‘user-123’,
‘name’: ‘Alice’,
‘email’: ‘alice@example.com’,
‘plan’: {
‘type’: ‘premium’, // Freezedでは、型を識別するためにカスタムフィールドを使うことが多い
‘hasExtraFeatures’: true,
}
},
‘message’: ‘Welcome, Alice!’,
};
final ApiResponse response1 = ApiResponse.fromJson(json1);
print(‘Response 1: $response1’);
// User plan type check using pattern matching
response1.user.plan.when(
free: () => print(‘Plan: Free’),
paid: (expiresAt) => print(‘Plan: Paid, Expires: $expiresAt’),
premium: (hasExtraFeatures) => print(‘Plan: Premium, Extra Features: $hasExtraFeatures’),
);
// Output:
// Response 1: ApiResponse(user: User(id: user-123, name: Alice, email: alice@example.com, plan: SubscriptionPlan.premium(hasExtraFeatures: true)), message: Welcome, Alice!)
// Plan: Premium, Extra Features: true

print(‘\n—\n’);

// 例2: 有料プラン(終了日時がnullでない)のレスポンス
final Map json2 = {
‘user’: {
‘id’: ‘user-456’,
‘name’: ‘Bob’,
// ‘email’ is missing, which is fine for String?
‘plan’: {
‘type’: ‘paid’,
‘expiresAt’: ‘2024-12-31T23:59:59Z’, // ISO 8601 format is good for DateTime
}
},
‘message’: ‘Your subscription is active.’,
};
final ApiResponse response2 = ApiResponse.fromJson(json2);
print(‘Response 2: $response2’);
response2.user.plan.when(
free: () => print(‘Plan: Free’),
paid: (expiresAt) => print(‘Plan: Paid, Expires: $expiresAt’),
premium: (hasExtraFeatures) => print(‘Plan: Premium, Extra Features: $hasExtraFeatures’),
);
// Output:
// Response 2: ApiResponse(user: User(id: user-456, name: Bob, email: null, plan: SubscriptionPlan.paid(expiresAt: 2024-12-31T23:59:59.000Z)), message: Your subscription is active.)
// Plan: Paid, Expires: 2024-12-31 23:59:59.000Z

print(‘\n—\n’);

// 例3: ‘email’ が null の場合
final Map json3 = {
‘user’: {
‘id’: ‘user-789’,
‘name’: ‘Charlie’,
‘plan’: {
‘type’: ‘free’,
}
},
‘message’: ‘Welcome aboard!’,
};
final ApiResponse response3 = ApiResponse.fromJson(json3);
print(‘Response 3: $response3’);
response3.user.plan.when(
free: () => print(‘Plan: Free’),
paid: (expiresAt) => print(‘Plan: Paid, Expires: $expiresAt’),
premium: (hasExtraFeatures) => print(‘Plan: Premium, Extra Features: $hasExtraFeatures’),
);
// Output:
// Response 3: ApiResponse(user: User(id: user-789, name: Charlie, email: null, plan: SubscriptionPlan.free()), message: Welcome aboard!)
// Plan: Free

print(‘\n—\n’);

// !!! エラーケース: ‘name’ が欠損している場合 (非Null許容型) !!!
// final Map invalidJson = {
// ‘user’: {
// ‘id’: ‘user-error’,
// // ‘name’ is missing
// ‘plan’: { ‘type’: ‘free’ }
// },
// ‘message’: ‘This will fail.’,
// };
// try {
// ApiResponse.fromJson(invalidJson);
// } catch (e) {
// print(‘Error parsing invalid JSON: $e’);
// // Expected Error: Missing key: name.
// }
}

解説:

  • `SubscriptionPlan`は`sealed class`(`freezed`では`union`と表現)として定義されており、`free`, `paid`, `premium`のいずれかの状態を取りうることを明確にしています。
  • JSONの`type`フィールド(カスタム)とDartの`SubscriptionPlan`の各ファクトリコンストラクタをマッピングすることで、JSONの構造をDartの型システムで表現しています。
  • `User`クラスの`email`は`String?`とすることで、JSONに存在しない、あるいは`null`でも問題ないことを示しています。
  • `ApiResponse.fromJson`メソッドは、`_$ApiResponseFromJson`によって自動生成され、ネストされた`User`オブジェクトや`SubscriptionPlan`オブジェクトも再帰的にパースします。
  • `when`メソッドによるパターンマッチングは、`SubscriptionPlan`の各状態を安全に分岐処理するための`freezed`の強力な機能です。これにより、`null`チェックを意識することなく、各プラン固有のロジックを記述できます。
  • 非Null許容型フィールド(`name`など)がJSONに存在しない、または`null`の場合は、`json_annotation`がエラーを発生させ、開発者に問題点を早期に知らせます。

3.2. `json_serializable` を用いた、よりシンプルなレスポンスのパース

APIレスポンスが比較的シンプルで、不変性や代数的データ型のような高度な機能が不要な場合は、`json_serializable`が手軽で強力な選択肢となります。

シナリオ:

シンプルな商品リストのレスポンス。

import ‘package:json_annotation/json_annotation.dart’;

part ‘product_response.g.dart’;

@JsonSerializable()
class Product {
final String id;
final String name;
final double price;
final String? description; // Descriptionはnullかもしれない

Product({
required this.id,
required this.name,
required this.price,
this.description,
});

factory Product.fromJson(Map json) => _$ProductFromJson(json);
Map toJson() => _$ProductToJson(this);
}

@JsonSerializable()
class ProductListResponse {
final List products; // Productのリスト。空リストはOKだが、nullはNG
final int totalCount;

ProductListResponse({
required this.products,
required this.totalCount,
});

factory ProductListResponse.fromJson(Map json) => _$ProductListResponseFromJson(json);
Map toJson() => _$ProductListToJson(this);
}

// — JSON パースと利用例 —

void main() {
// 例1: 正常な商品リストレスポンス
final Map json1 = {
‘products’: [
{‘id’: ‘prod-001’, ‘name’: ‘Laptop’, ‘price’: 1200.50, ‘description’: ‘Powerful computing’},
{‘id’: ‘prod-002’, ‘name’: ‘Mouse’, ‘price’: 25.00, ‘description’: null}, // Description is null
{‘id’: ‘prod-003’, ‘name’: ‘Keyboard’, ‘price’: 75.75}, // Description is missing
],
‘totalCount’: 3,
};
final ProductListResponse response1 = ProductListResponse.fromJson(json1);
print(‘Response 1: Total count = ${response1.totalCount}’);
for (final product in response1.products) {
print(‘ – Product: ${product.name}, Price: ${product.price}, Description: ${product.description ?? “N/A”}’);
}
// Output:
// Response 1: Total count = 3
// – Product: Laptop, Price: 1200.5, Description: Powerful computing
// – Product: Mouse, Price: 25.0, Description: null
// – Product: Keyboard, Price: 75.75, Description: N/A

print(‘\n—\n’);

// 例2: 商品リストが空の場合
final Map json2 = {
‘products’: [],
‘totalCount’: 0,
};
final ProductListResponse response2 = ProductListResponse.fromJson(json2);
print(‘Response 2: Total count = ${response2.totalCount}’);
print(‘Number of products: ${response2.products.length}’);
// Output:
// Response 2: Total count = 0
// Number of products: 0

print(‘\n—\n’);

// !!! エラーケース: ‘products’ が null の場合 (List は非Null許容型) !!!
// final Map invalidJson = {
// ‘products’: null, // null is not allowed for List
// ‘totalCount’: 5,
// };
// try {
// ProductListResponse.fromJson(invalidJson);
// } catch (e) {
// print(‘Error parsing invalid JSON: $e’);
// // Expected Error: Invalid value: Expected a list, but received null.
// }

// !!! エラーケース: ‘totalCount’ が String の場合 !!!
// final Map typeMismatchJson = {
// ‘products’: [],
// ‘totalCount’: ‘five’, // Expected int, got String
// };
// try {
// ProductListResponse.fromJson(typeMismatchJson);
// } catch (e) {
// print(‘Error parsing JSON with type mismatch: $e’);
// // Expected Error: Invalid value: Not an int.
// }
}

解説:

  • `Product`クラスと`ProductListResponse`クラスは、`@JsonSerializable()`アノテーションを付与され、JSONパースに必要なメソッドがコード生成されます。
  • `Product.description`は`String?`として定義されており、JSONに`description`キーが存在しない、あるいは値が`null`であっても、エラーなくパースされます。
  • `ProductListResponse.products`は`List`(非Null許容型リスト)として定義されているため、JSONの`products`キーが`null`であったり、キー自体が存在しない場合は、実行時エラーとなります。
  • `json_annotation`は、JSONの値とDartの型定義の不一致(例: `totalCount`が`String`なのに`int`としてパースしようとした場合)を検出し、エラーを発生させます。

4. まとめ:どちらを選ぶべきか?

  • `freezed`:
  • 長所: 不変性、代数的データ型、パターンマッチングによる表現力と安全性の向上。複雑な状態管理や、APIレスポンスのバリエーションが多い場合に特に強力。
  • 短所: `json_serializable`単体よりも学習コストがやや高い。
  • 推奨ケース: アプリケーションの状態管理が複雑、APIレスポンスが多岐にわたる、コードの堅牢性を極限まで高めたい場合。
  • `json_serializable`:
  • 長所: シンプルで導入が容易。Dartの型システムに素直に従い、コンパイル時の型安全性を保証。
  • 短所: 不変性やパターンマッチングのような高度な機能は提供しない。
  • 推奨ケース: APIレスポンスが比較的シンプル、手軽にJSONパースの型安全性を確保したい場合。

最終的な選択は、プロジェクトの規模、複雑さ、そしてチームの慣れによって変わります。しかし、どちらのライブラリを選択するにしても、DartのNull安全システムを最大限に活用し、APIレスポンスの型定義を厳密に行うことが、堅牢で保守性の高いアプリケーションを構築するための鍵となります。

今日紹介したコード例を参考に、皆さんのプロダクションコードに、より洗練されたJSONパースの仕組みを導入してみてください。APIとの連携が、より安全で、そして心地よいものになることを願っています。

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