公開日 2026-07-13

Flutterでjson_serializable + build_runnerを使ってJSONモデルを型安全に扱う

Flutterで json_serializable と build_runner を導入し、ネストした JSON モデルの fromJson / toJson 自動生成と再生成の運用を最小構成で確認できるようにする。

目次

  1. 1. ゴールと非対象
  2. 対象読者
  3. この記事で到達する状態
  4. 非対象
  5. 2. 先に生成の流れを掴む
  6. 3. プロジェクトを作成し、依存を追加する
  7. 3-1. 環境構築がまだなら先に済ませる
  8. 3-2. Flutter プロジェクトを作成する
  9. 3-3. エミュレーターを起動する
  10. 3-4. パッケージを追加する
  11. 4. 注釈付きの lib/main.dart を作成する
  12. コードのポイント
  13. 5. build_runner で main.g.dart を生成する
  14. 6. 自動生成に任せる範囲と運用のコツ
  15. 6-1. ネストした toJson() は explicitToJson: true を意識する
  16. 6-2. JSON のキー名が snake_case なら、Dart 側は camelCase で持てる
  17. 6-3. モデルを変えたあとに再生成しないと、エラー位置が分かりにくくなる
  18. 6-4. 手書きモデルと生成モデルの違い
  19. 7. まとめ

FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理) の次に整理しておきたいのが、JSON モデルの持ち方です。前記事では fromJson を手書きしましたが、モデルが増えるほどキー名変更やネスト構造の追従が重くなります。この記事では json_serializablebuild_runner を使い、ネストした JSON を Dart モデルへ変換し、toJson() で API 送信用の Map に戻すところまでを最小構成で確認します。

1. ゴールと非対象

対象読者

  • Flutter の環境構築、Dart 基礎、REST API 通信の入口までは終わっている人
  • JSON モデルを毎回手書きしていて、保守の増え方が気になってきた人
  • 今後の Riverpod、CRUD、フォーム記事へ進む前に、モデル生成の土台を先にそろえたい人

この記事で到達する状態

  • json_annotation / json_serializable / build_runner の役割を説明できる
  • ネストした JSON を ShipmentPageResponse.fromJson() で Dart モデルへ変換できる
  • toJson() を使って送信用の Map を作り、jsonEncode() で確認できる
  • モデルを変更したあとに、どのコマンドを再実行すべきか判断できる

非対象

  • freezed を使った immutable model 生成
  • Riverpod や Bloc などの状態管理
  • HTTP 通信そのものの説明
  • 複数ファイル分割や Repository パターン

今回はモデル生成の入口に絞ります。通信部分は前記事のまま使い、手書きモデルだけを生成コードへ置き換えるつもりで読むとつながりやすくなります。

2. 先に生成の流れを掴む

今回の流れは次の通りです。

flowchart LR
  A[JSON文字列 or response.body] --> B[jsonDecodeでMap<String, dynamic>へ変換]
  B --> C[ShipmentPageResponse.fromJson]
  C --> D[ShipmentSummary / CustomerInfo / PageInfo]
  D --> E[UIで表示]
  D --> F[toJsonでMapへ戻す]
  F --> G[jsonEncodeで送信・確認]
  H[JsonSerializable アノテーションをモデルに付ける] --> I[build_runner が main.g.dart を生成]
  I --> C
  I --> F

3 つのパッケージはそれぞれ役割が異なります。

  • json_annotation: モデルに付ける注釈を提供する
  • json_serializable: 注釈を読んで fromJson / toJson の実装コードを生成する
  • build_runner: 生成処理を実行する

開発者が書くのはモデル定義です。実際の変換コードは main.g.dart に出るため、キーを追加するたびに手で Map を書き直す手間を省けます。

3. プロジェクトを作成し、依存を追加する

3-1. 環境構築がまだなら先に済ませる

環境構築がまだの場合は Windows 11で始めるFlutter開発環境 を先に参照してください。

3-2. Flutter プロジェクトを作成する

次のコマンドでプロジェクトを作成します。

flutter create my_json_model_app
cd my_json_model_app

3-3. エミュレーターを起動する

利用可能なエミュレーターを確認します。

flutter emulators

表示された ID を指定して起動します。

flutter emulators --launch <emulator_id>

3-4. パッケージを追加する

今回は次の 3 つを追加します。

flutter pub add json_annotation
flutter pub add --dev build_runner
flutter pub add --dev json_serializable

役割は次の通りです。

  • json_annotation: モデルへ @JsonSerializable()@JsonKey() を付けるための通常依存
  • build_runner: 生成処理を回すための dev dependency
  • json_serializable: *.g.dart を生成するための dev dependency

この段階では HTTP パッケージを増やしません。前記事の通信コードに接続する前に、まずモデル生成だけを単独で確認します。

4. 注釈付きの lib/main.dart を作成する

この lib/main.dart は、JSON 文字列をモデルへ変換し、再び toJson() で戻す流れを 1 ファイルで確認するサンプルです。生成コードに任せる境界と、UI 側が何だけを呼べばよいかを見ます。

lib/main.dart は次の内容で作成します。

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:json_annotation/json_annotation.dart';

part 'main.g.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'json_serializable demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
      ),
      home: const ShipmentJsonDemoPage(),
    );
  }
}

class ShipmentJsonDemoPage extends StatefulWidget {
  const ShipmentJsonDemoPage({super.key});

  @override
  State<ShipmentJsonDemoPage> createState() => _ShipmentJsonDemoPageState();
}

class _ShipmentJsonDemoPageState extends State<ShipmentJsonDemoPage> {
  ShipmentPageResponse? _response;
  String _encodedJson = '';
  String? _errorMessage;
  bool _isLoading = false;

  @override
  void initState() {
    super.initState();
    _loadSample();
  }

  Future<void> _loadSample() async {
    setState(() {
      _isLoading = true;
      _errorMessage = null;
    });

    await Future<void>.delayed(const Duration(milliseconds: 250));

    try {
      final Map<String, dynamic> json =
          jsonDecode(_shipmentPageJson) as Map<String, dynamic>;
      final ShipmentPageResponse response = ShipmentPageResponse.fromJson(json);
      final String encodedJson =
          JsonEncoder.withIndent('  ').convert(response.toJson());

      if (!mounted) {
        return;
      }

      setState(() {
        _response = response;
        _encodedJson = encodedJson;
      });
    } catch (error) {
      if (!mounted) {
        return;
      }

      setState(() {
        _response = null;
        _encodedJson = '';
        _errorMessage = 'JSON 変換に失敗しました。詳細: $error';
      });
    } finally {
      if (!mounted) {
        return;
      }

      setState(() {
        _isLoading = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    final ColorScheme colorScheme = Theme.of(context).colorScheme;

    return Scaffold(
      appBar: AppBar(
        title: const Text('json_serializable で JSON モデル化'),
      ),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: <Widget>[
          Card(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text(
                    '出荷一覧レスポンスを fromJson で読み込み、toJson で戻します。',
                    style: Theme.of(context).textTheme.titleMedium,
                  ),
                  const SizedBox(height: 8),
                  const Text(
                    '今回は通信ではなくモデル生成が主題なので、JSON サンプル文字列を直接読みます。',
                  ),
                  const SizedBox(height: 12),
                  FilledButton.icon(
                    onPressed: _isLoading ? null : _loadSample,
                    icon: const Icon(Icons.sync),
                    label: const Text('サンプルJSONを再読込'),
                  ),
                ],
              ),
            ),
          ),
          const SizedBox(height: 16),
          if (_isLoading)
            const Padding(
              padding: EdgeInsets.symmetric(vertical: 32),
              child: Center(child: CircularProgressIndicator()),
            )
          else if (_errorMessage != null)
            Card(
              color: colorScheme.errorContainer,
              child: Padding(
                padding: const EdgeInsets.all(16),
                child: Text(
                  _errorMessage!,
                  style: TextStyle(color: colorScheme.onErrorContainer),
                ),
              ),
            )
          else if (_response != null) ...<Widget>[
            _PageSummaryCard(page: _response!.page, itemCount: _response!.items.length),
            const SizedBox(height: 16),
            Text(
              '変換後のモデル',
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 8),
            ..._response!.items.map(
              (ShipmentSummary shipment) => Padding(
                padding: const EdgeInsets.only(bottom: 12),
                child: _ShipmentCard(shipment: shipment),
              ),
            ),
            const SizedBox(height: 12),
            Text(
              'toJson の出力確認',
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 8),
            Container(
              padding: const EdgeInsets.all(12),
              decoration: BoxDecoration(
                color: colorScheme.surfaceContainerHighest,
                borderRadius: BorderRadius.circular(12),
              ),
              child: SelectableText(_encodedJson),
            ),
          ],
        ],
      ),
    );
  }
}

class _PageSummaryCard extends StatelessWidget {
  const _PageSummaryCard({required this.page, required this.itemCount});

  final PageInfo page;
  final int itemCount;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: <Widget>[
            _SummaryItem(label: '現在ページ', value: '${page.current}'),
            _SummaryItem(label: '表示件数', value: '$itemCount'),
            _SummaryItem(label: '総件数', value: '${page.totalCount}'),
          ],
        ),
      ),
    );
  }
}

class _SummaryItem extends StatelessWidget {
  const _SummaryItem({required this.label, required this.value});

  final String label;
  final String value;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        Text(label, style: Theme.of(context).textTheme.labelMedium),
        const SizedBox(height: 4),
        Text(value, style: Theme.of(context).textTheme.titleLarge),
      ],
    );
  }
}

class _ShipmentCard extends StatelessWidget {
  const _ShipmentCard({required this.shipment});

  final ShipmentSummary shipment;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: <Widget>[
                Text(
                  shipment.code,
                  style: Theme.of(context).textTheme.titleMedium,
                ),
                Chip(label: Text(shipment.status)),
              ],
            ),
            const SizedBox(height: 8),
            Text('取引先: ${shipment.customer.name} (${shipment.customer.code})'),
            const SizedBox(height: 8),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: shipment.tags
                  .map((String tag) => Chip(label: Text(tag)))
                  .toList(),
            ),
          ],
        ),
      ),
    );
  }
}

@JsonSerializable(explicitToJson: true)
class ShipmentPageResponse {
  const ShipmentPageResponse({required this.items, required this.page});

  final List<ShipmentSummary> items;
  final PageInfo page;

  factory ShipmentPageResponse.fromJson(Map<String, dynamic> json) =>
      _$ShipmentPageResponseFromJson(json);

  Map<String, dynamic> toJson() => _$ShipmentPageResponseToJson(this);
}

@JsonSerializable(explicitToJson: true)
class ShipmentSummary {
  const ShipmentSummary({
    required this.id,
    required this.code,
    required this.status,
    required this.customer,
    required this.tags,
  });

  final int id;
  final String code;
  final String status;
  final CustomerInfo customer;
  final List<String> tags;

  factory ShipmentSummary.fromJson(Map<String, dynamic> json) =>
      _$ShipmentSummaryFromJson(json);

  Map<String, dynamic> toJson() => _$ShipmentSummaryToJson(this);
}

@JsonSerializable()
class CustomerInfo {
  const CustomerInfo({required this.code, required this.name});

  final String code;
  final String name;

  factory CustomerInfo.fromJson(Map<String, dynamic> json) =>
      _$CustomerInfoFromJson(json);

  Map<String, dynamic> toJson() => _$CustomerInfoToJson(this);
}

@JsonSerializable(fieldRename: FieldRename.snake)
class PageInfo {
  const PageInfo({
    required this.current,
    required this.pageSize,
    required this.totalCount,
  });

  final int current;
  final int pageSize;
  final int totalCount;

  factory PageInfo.fromJson(Map<String, dynamic> json) =>
      _$PageInfoFromJson(json);

  Map<String, dynamic> toJson() => _$PageInfoToJson(this);
}

const String _shipmentPageJson = '''
{
  "items": [
    {
      "id": 101,
      "code": "S-1001",
      "status": "ピッキング中",
      "customer": {
        "code": "C001",
        "name": "東京商事"
      },
      "tags": ["priority", "cool-chain"]
    },
    {
      "id": 102,
      "code": "S-1002",
      "status": "確認待ち",
      "customer": {
        "code": "C002",
        "name": "大阪物流"
      },
      "tags": ["fragile"]
    },
    {
      "id": 103,
      "code": "S-1003",
      "status": "出荷済み",
      "customer": {
        "code": "C003",
        "name": "名古屋販売"
      },
      "tags": ["export", "morning"]
    }
  ],
  "page": {
    "current": 1,
    "page_size": 3,
    "total_count": 12
  }
}
''';

コードのポイント

part 'main.g.dart'; が生成コードの接続点になる

part 'main.g.dart';

この宣言があることで、build_runner が生成した _$ShipmentPageResponseFromJson などを同じライブラリから参照できます。生成コードを直接読む前に、まずここが接続点だと捉えると整理しやすくなります。

② ネストしたモデルを toJson() まで辿るには explicitToJson: true を付ける

@JsonSerializable(explicitToJson: true)
class ShipmentPageResponse {
  const ShipmentPageResponse({required this.items, required this.page});

ShipmentPageResponse の中には ShipmentSummaryPageInfo が入っているため、ネスト先も toJson() で変換したい意図を明示しています。親モデルだけでなく、入れ子のオブジェクトも JSON 化したいときの基本設定です。

③ snake_case の JSON キーは fieldRename で Dart 側へ寄せる

@JsonSerializable(fieldRename: FieldRename.snake)
class PageInfo {

JSON の page_sizetotal_count を、Dart 側では pageSizetotalCount のまま扱えます。API の命名規則を UI 側へ持ち込まずに済むため、モデルの読みやすさを保ちやすくなります。

④ UI 側は fromJson()toJson() を呼ぶだけでよい

final Map<String, dynamic> json =
    jsonDecode(_shipmentPageJson) as Map<String, dynamic>;
final ShipmentPageResponse response = ShipmentPageResponse.fromJson(json);
final String encodedJson =
    JsonEncoder.withIndent('  ').convert(response.toJson());

変換の詳細は生成コードに任せ、画面側は入力と出力の呼び出しだけを持っています。これにより、UI は「JSON をモデルにする」「モデルを JSON に戻す」という役割に集中できます。

前記事の response.body を使う場合も、置き換えるのは jsonDecode の入力元だけです。jsonDecode(response.body) の結果を ShipmentPageResponse.fromJson() に渡せば、以降も同じように扱えます。

5. build_runnermain.g.dart を生成する

lib/main.dart を保存したら、プロジェクト直下で次のコマンドを実行します。

dart run build_runner build --delete-conflicting-outputs

生成が終わると lib/main.g.dart が作られます。これは自動生成物のため、手編集は不要です。モデルのフィールドや注釈を変更したら、同じコマンドをもう一度流します。

既存の記事や古い資料では flutter pub run build_runner build --delete-conflicting-outputs と書かれていることがあります。どちらも同じ生成処理を実行します。

頻繁にモデルを触る間だけ、監視実行へ切り替える方法もあります。

dart run build_runner watch --delete-conflicting-outputs

生成が終わったら、次のコマンドでアプリを起動します。

flutter run

fromJson()toJson() が正しく生成されていれば、起動直後に出荷一覧と toJson の整形結果が表示されます。

VS Code の Explorer に main.dart と main.g.dart が生成されている 出荷一覧を fromJson で読み込み、S-1001(ピッキング中)と S-1002(確認待ち)が表示されているデモ画面 S-1003(出荷済み)のカードと toJson の出力確認エリア toJson で生成された JSON の全体出力(items 配列と page オブジェクトを含む)

6. 自動生成に任せる範囲と運用のコツ

導入直後に迷いやすい点をまとめます。

6-1. ネストした toJson()explicitToJson: true を意識する

親モデルの toJson() から子モデルの toJson() を明示的に呼びたいときは、親へ explicitToJson: true を付けておくと読みやすくなります。今回なら ShipmentPageResponseShipmentSummary がそれに当たります。

6-2. JSON のキー名が snake_case なら、Dart 側は camelCase で持てる

PageInfo のようにクラス単位でそろっているなら fieldRename: FieldRename.snake が素直です。1 つだけ例外がある場合は、@JsonKey(name: '...') をそのフィールドだけに付ける方法も選べます。

6-3. モデルを変えたあとに再生成しないと、エラー位置が分かりにくくなる

フィールド追加や名前変更のあとに main.g.dart を更新しないと、コンパイルエラーが UI 側ではなく生成物側に出ることがあります。モデルを触った直後に build_runner を回す流れへ寄せると、確認箇所がぶれにくくなります。

6-4. 手書きモデルと生成モデルの違い

観点手書き fromJson / toJsonjson_serializable
最初の1本速い準備が少し増える
フィールド追加全モデルを手で直す注釈側を直して再生成する
ネスト JSON書き漏れが出やすい親子関係をクラスで保ちやすい
API 契約の変更追従キー名の散在が起きやすいモデル定義へ寄せやすい

REST API 入門の段階では手書きでも十分です。モデル数が増え始めたところで生成へ切り替えると、保守コストを抑えやすくなります。

7. まとめ

json_serializablebuild_runner を入れると、JSON モデルの責務を Dart クラスへ寄せたまま、変換コードだけを自動生成できます。前記事の FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理) で手書きしていた fromJson をこの形へ置き換えるだけでも、後続の状態管理や CRUD で追う場所を減らせます。

次にモデル数が増える記事へ進む前に、まずはこのサンプルを手元で動かし、statuspage_size のキー名を変えてから build_runner を再実行してみてください。どの差分がモデル定義に集まるかを一度体感しておくと、以降の Flutter 記事でも読み方がそろいます。

シリーズ 18/38

このシリーズ

Flutter導入と基礎

  1. 1. Windows 11で始めるFlutter開発環境:Android Emulatorで動かすまで
  2. 2. Flutter + FVM で開発環境のバージョンを固定する
  3. 3. Flutterで画像・SVG・アイコンを管理する(flutter_gen最小構成)
  4. 4. Flutterで最初に詰まりやすいDartの書き方:final・const・null safety・async/await を最初に整理する
  5. 5. DartのStream入門(非同期データの流れをつかむ)
  6. 6. FlutterのWidgetライフサイクル入門(initState / dispose で詰まらないために)
  7. 7. FlutterでBuildContextとKeyを理解する
  8. 8. Flutterのレイアウト入門(Column / Row / Stack の使い分け)
  9. 9. Flutterのテーマ設計入門(ThemeData + Theme Extension)
  10. 10. FlutterでMediaQueryとLayoutBuilderを使って画面サイズに対応する(スマホ・タブレット両対応)
  11. 11. FlutterのContainerとSizedBoxを使いこなす(余白・サイズ・装飾の基本)
  12. 12. FlutterのListViewとGridViewで一覧画面を作る(基本パターン)
  13. 13. Flutterのダイアログ・スナックバー・ボトムシートを使う(確認・通知UIの基本)
  14. 14. FlutterのTabBarとBottomNavigationBarで複数画面を切り替える
  15. 15. Flutterでカスタムウィジェットを作る入門(StatelessWidget の分割と再利用)
  16. 16. Flutterのルーティング入門(Navigator と go_router の使い分け)
  17. 17. FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理)
  18. 18. Flutterでjson_serializable + build_runnerを使ってJSONモデルを型安全に扱う 現在の記事
  19. 19. Flutterの状態管理入門(Riverpod最小構成)
  20. 20. Flutterでローディング・空状態・エラー表示を整える
  21. 21. Dart 3のsealed classとパターンマッチングで分岐を安全に書く
  22. 22. Flutterでgo_routerの認証ガードを実装する(redirect最小構成)
  23. 23. Flutterで端末設定と利用者設定を保存する(SharedPreferencesとsecure storageの使い分け)
  24. 24. Flutterでログイン状態を保持する(JWT + secure storage 最小構成)
  25. 25. Flutterアプリを日本語化する(l10n + arb 最小構成)
  26. 26. Flutterで業務用バーコード読み取りアプリを作る(最小構成)
  27. 27. Flutterでスキャン入力を受けて処理する
  28. 28. FlutterでGS1-128バーコードを解析する
  29. 29. Flutterで単一画面の入力フローを作る
  30. 30. Flutterで複数画像の添付UIを作る
  31. 31. Flutterでデータをファイルに書き出す
  32. 32. permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth)
  33. 33. Flutterアプリのネイティブ設定を整える(アプリ名 / アイコン / スプラッシュ / 署名)
  34. 34. Flutterの環境切替と配布前チェック(flavor / release build / 権限確認)
  35. 35. FlutterのWidgetテスト入門(画面ロジックを壊さない最小構成)
  36. 36. FlutterのIntegration Test入門(ログインから一覧表示まで確認する)
  37. 37. Flutterアプリを社内配布する(Android APK サイドロード + MDM 概要)
  38. 38. Sentryでクラッシュとエラーを検知する(Flutter最小構成)