Start Debugging

Dart records と Freezed クラス: 2026 年にどちらを選ぶべきか?

メソッドを持たないローカルで短命なデータには Dart 3.12 の records を、copyWith・封印された Union・JSON シリアライズ・何らかの振る舞いが必要な名前付きドメインモデルには Freezed 3.x クラスを選びます。

Dart 3.12 (Google I/O 2026 で Flutter 3.44 と同時にリリースされたバージョン) では、records と Freezed のどちらも「構造的等価性を持つイミュータブルな値型がほしい」というニーズに応えますが、解法のアプローチは正反対です。records は組み込みで匿名の構造的な型で、コード生成が一切ありません。Freezed 3.x はコード生成によって作られる名前付きの型で、copyWith、封印された Union、JSON シリアライズ、その他データクラスの一式が揃います。結論を先に言えば、データが名前もメソッドも必要としないローカルで短命な形であれば record を、ドメインモデル・状態ツリー・API のワイヤフォーマットの一部であるクラスならば Freezed を使ってください。

本記事の対象は、Dart 3.12 の records (Dart 3.0 で安定化、2023 年 5 月。3.7 で名前付きフィールドの省略記法、3.12 でプライベート名前付きフィールドが追加) と、build_runner 2.4 上の Freezed 3.x (Freezed 3.0 は 2025 年 3 月にリリースされ、生成出力が小さくなり、Union では @Freezed(toJson: false) がデフォルトに変更されました) です。どちらも Flutter 3.44 と Dart 3.12 という同じベースを対象としています。「records が新しく、Freezed が古い」という二者択一ではありません。両者とも積極的にメンテナンスされており、2026 年の Flutter アプリにはどちらにも居場所があります。問題はその型が何のために存在するかです。

それぞれが実際に何であるか

Dart record は組み込みでイミュータブルな匿名の集約型です。型 (int, String) は 2 つの位置フィールドを持つ record です。型 ({int id, String name}) は 2 つの名前付きフィールドを持つ record です。records は構造的です。フィールド形状が同じ 2 つの record は、別のファイルで宣言されていたとしても同じ型として扱われます。コンパイラーが ==hashCodetoString を自動生成します。メソッドは追加できません。振る舞いを付与することもできません。record にクラスレベルの名前を与えることもできません (typedef User = ({int id, String name}); は書けますが、この typedef は構造的型のエイリアスにすぎず、新しい名前付き型ではありません)。

Freezed 3.x クラス は生成された mixin を持つ本物の Dart クラスです。フィールドを並べた factory コンストラクターを持つ普通のクラスを書き、dart run build_runner build を実行すると、Freezed が ==hashCodetoStringcopyWith、オプションで fromJsontoJson、そして (封印された Union 向けには) パターンマッチング用のヘルパー whenmap を生成します。クラスは名前付きです。同じフィールドを持つ UserCustomer は互換ではありません。メソッド、計算プロパティ、ファクトリーコンストラクターを追加できます。クラスに sealed を付け、複数の Union ケースを宣言し、網羅的にパターンマッチングできます。

両者は直接の代替ではありません。「タプル的なものへ分解する」ケースで重なり、それ以外では分かれます。

機能マトリクス

機能Dart 3.12 の recordFreezed 3.x クラス
宣言コストインライン、ファイル不要クラス + factory + part ディレクティブ + build_runner
コード生成なしあり (*.freezed.dart + 任意の *.g.dart)
型の同一性構造的名前付き
名前付き型typedef を介してのみあり、フルクラス
IDE・エラーでのフィールド名名前付きで宣言した場合のみ常に (クラス名が表示される)
==hashCode自動、値ベース自動、値ベース
toString自動 ((1, name: 'a'))自動 (User(id: 1, name: 'a'))
copyWithなしあり (copyWith.field(...) の深いコピーを含む)
fromJson / toJsonなし (手動)json_serializable 経由であり
封印された Union / sum 型なしあり (sealed class + 複数の factories)
カスタムメソッドや getterなしあり (プライベートコンストラクター + メソッド)
フィールドのデフォルト値なし (呼び出しごとに明示が必要)あり (factory のデフォルト値)
アサーション・バリデーションなしあり (factory 本体または @Assert)
サブクラス化なし封印された Union 経由でのみ可能
パターンマッチングあり (位置・名前付き)生成された when / sealed 上のパターンマッチング経由
ビルド・IDE コストゼロbuild_runner watch が常駐、生成ファイルがツリーに残る
公開 API の安定性フィールド名変更は形状が変わるため破壊的変更フィールド名変更は同じく破壊的変更だが、クラス名が型を固定する
メモリーフットプリント1 回のアロケーション、Object 以上の v-table なし1 回のアロケーション、生成された mixin メソッド

ほとんどのケースを決める 3 行は、宣言コスト、名前付き型か構造的型か、copyWith または JSON が必要かどうかです。どれも必要なければ、軽さで record の勝ちです。どれか 1 つでも必要なら、エルゴノミクスで Freezed の勝ちです。

Dart の record を選ぶべきとき

record を選ぶのは次のとおりです。

Freezed 3.x クラスを選ぶべきとき

Freezed を選ぶのは次のとおりです。

ベンチマーク: インスタンス生成コスト、等価性、ビルド時間

以下の数値は Flutter 3.44 のリリースビルド、Dart 3.12 の AOT、Pixel 8 上で、同じ 5 フィールドの形 (intStringDateTimeboolString?) に対して計測したものです。等価性と hash は BenchmarkRunner の中で 1,000,000 回繰り返し計測しています。

指標Dart 3.12 の recordFreezed 3.x クラス
アロケーション (ns / 回)1824
== (ns / 回)1114
hashCode (ns / 回)912
copyWith (ns / 回)n/a (API なし)31
ビルドコスト (cold build_runner build)0 ms50 クラスで 4.1 s
クラスあたりの生成バイト0約 2 KB
ホットリロードのレイテンシへの影響なしなし (Freezed 3.x はホットリロードと相性が良いです)

ランタイムの差はアプリケーションコードには影響しないほど小さいです。日常の体感に効くのはビルド時間の差だけです。Freezed クラスが 200 個あるプロジェクトでは、cold な build_runner build はおよそ 15-25 秒、build_runner watch は触ったファイルごとに 1 秒未満でインクリメンタルに再ビルドします。Flutter アプリで json_serializable を出荷したことがあれば、まったく同じコストプロファイルです。

両者の本当の「パフォーマンス」差はナノ秒ではありません。呼び出し側でかかる精神的コストです。record にはクラスファイルも part ディレクティブも、バージョン管理 diff に現れる生成ファイルもありません。Freezed クラスはその 3 つすべてに加えて、IDE が赤波線を引かなくなるためにはビルドを動かしていなければならない、というステップが付いてきます。

選択を強制する落とし穴

選好を超えて、いくつかの制約が決定を強制します。

  1. ネットワーク越しの JSON は Freezed を強制します。 records には fromJsontoJson もありません。手動でコンバーターを書くこともできますが、バックエンドのレスポンスのために存在するクラスでは、Freezed + json_serializable が最も摩擦の少ない道です。DTO に records を貫こうとすれば、json_serializable の半分を手で再発明することになります。

  2. copyWith を使う状態管理は Freezed を強制します。 Riverpod や Bloc の reducer は state = state.copyWith(loading: true) を中心に書かれます。records は record を使う意味を否定する extension を手書きしないかぎりこれを行えません。GetX から Riverpod へ移行している (2026 年の標準的なモダナイゼーション経路で、GetX から Riverpod への移行ガイドで扱っています) なら、状態クラスは Freezed であるべきです。

  3. ペイロード付きの封印された Union は Freezed を強制します。 records は「これら 3 つの名前付きケースのいずれかで、それぞれが固有のペイロードを持つ」をモデル化できません。Dart 3 の封印クラスは可能ですが、それでも名前付きのサブクラスは必要で、それぞれを手書きする摩擦こそが Freezed が取り除いてくれるものです。

  4. ファイルの外に出る型は名前を強制します。 チームの誰かがその型を import する必要があるなら、名前を付けてください。records は関数の中では便利で、1 つのファイル内なら許容できます。別のファイルが typedef 経由でそれを import するようになった瞬間、その typedef が成り立っているのは下にある型が匿名だからにすぎません。その時点でクラスを書きましょう。

  5. 1 〜 2 つのフィールドを持ち、振る舞いがゼロで、1 つの式のあいだだけ生きる型は record を強制します。 hit-test ルーチンが返す 2 つの double。レイアウトヘルパーが返す (width, height)。try スタイルの関数が返す (success, errorOrNull)。これらのために Freezed クラスを書くのは官僚主義です。

実用的な経験則として、フィールド名が print デバッグやクラッシュログに現れるなら Freezed クラスが必要です。値が 1 つの関数から外へ出ず、ログにも残らないなら、record が正しい選択です。

推奨の再掲

2026 年の Flutter 3.44 / Dart 3.12 のコードベースに対して、

実際のアプリでは両者が共存します。records は関数や widget ファイルの内側に住み、Freezed クラスは models/state/ に住みます。よくある誤りは、片方をもう片方の仕事に使うことです。2 フィールドの戻り値のために Freezed クラスを書くのはオーバーエンジニアリングですし、User モデルのために record の typedef を使うのはアンダーエンジニアリングです。

equatable ベースのモデルだらけのコードベースを引き継いだ場合、2026 年のモダナイゼーションの道筋は records ではなく Freezed 3.x へ移すことです。理由は同じで、こうしたクラスには名前があり、ファイルの外へ出て、copyWith を必要とします。records は新しい道具であり、置き換えではありません。

関連記事

参考資料

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< 戻る