dart3-idioms-and-coding-standards について
型設計、不変性、命名、全域的なドメイン関数に関する Dart 3 のコーディングスキルです。網羅的な分岐をコンパイラーで確認し、一貫したコード構成を目指す開発者向けです。
主な機能
- 01sealed 型の網羅的な switch
- 02クラス修飾子と enum の指針
- 03不変値型と安定した識別子
- 04全域的で例外を投げないドメインロジック
- 05Dart の命名規則と複雑度制限
- 06Dart 3 慣用表現の検査スクリプト
活用シーン
- 01閉じたドメイン結果のモデリング
- 02アプリ層をまたぐ不変値の設計
- 03switch のバリアント漏れ確認
- 04PR 前の Dart コード検査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill dart3-idioms-and-coding-standardsインストール後、Claude Code で/dart3-idioms-and-coding-standardsを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
examples/immutable_value.dartsealed_outcome.darttotal_domain_function.dartreferences/complexity-and-honesty.mdconstruct-verdict-table.mdimmutability-and-equality.mdscripts/check-dart3-idioms.sh詳細
Dart 3 の慣用表現とコーディング規約
最新の Dart 3.x を使い、値型を優先し、ドメインロジックには全域関数を用います。言語機能を採用する価値があるのは、実行時の見落としをコンパイルエラーに変えられる場合です。それ以外は飾りにすぎません。このスキルでは、各宣言の型付け、命名、不変性の維持、全域性の維持について定めます。こうした仕組みの上に成り立つエラー処理アーキテクチャについては、error-handling-typed-results を参照してください。
作業内容に応じて、次のリファレンスを確認してください。
references/construct-verdict-table.md— 機能ごとの判断表(sealed/enum/record/クラス修飾子/extension type/コード生成パッケージ)と、Use/Skip それぞれの根拠です。references/immutability-and-equality.md— 不変の値型、copyWith、==/hashCodeを手書きする場合、安定した同一性と値の等価性について説明しています。references/complexity-and-honesty.md— 根拠を示した長さ/ネストの上限と、late/!/dynamicによるごまかしを全面的に禁じる規則です。
PR の前に scripts/check-dart3-idioms.sh を実行してください。
絶対に守るべきルール
- シールド型または列挙型の
switchにdefault:やcase _:を含めてはいけません。 ワイルドカードを使うと、スイッチが永遠にコンパイル可能になり、その型が存在する唯一の理由であるコンパイル時の保証が失われます。つまり、バリアントを追加しても実行時に黙ってフォールスルーします。網羅性こそがこの仕組みのすべてです。 - クラス修飾子は、次の3つだけを使います。それ以外は無視してください。 コンパイラーに網羅性を検証させる閉じたバリアント集合には
sealed class、すべての具象リーフにはfinal class、テスト用フェイクが実装する境界にはabstract interface classを使います。具象型はデフォルトでfinalにします。base、extension type、プライマリーコンストラクター、マクロは使いません。 - それぞれ個別に処理できるケースからなる閉じた集合は
sealed階層で、ペイロードのない閉じた集合はenumでモデル化します。 まずenumを使い、いずれかのメンバーにフィールドが必要になった時点でsealed+final classに変更します。一部のメンバーにだけ適用されるデータのために、列挙型へ nullable フィールドを追加してはいけません。そうすると、コンパイラーが推論できない null チェックが、あらゆるアクセスで必要になります。 - レコードをレイヤーの境界を越えて渡してはいけません。 レコードは名前もなく、位置で識別され、ドキュメント化もされていない形状です。1つのレイヤー内で一時的に複数の値を返す用途には適しています。リポジトリーから返す、保存する、またはウィジェットのコンストラクターに渡す形状は、名前付きクラスにします。
- ドメイン値は不変にします。つまり、
finalフィールド、可能な場合はconstコンストラクター、値の等価性、派生値を作るcopyWithを使います。 ウィジェットに渡した値を変更すると、リビルドやゴールデンテストが破綻します。アナライザーが許す限り、finalのローカル変数とconstコンストラクターを優先してください。 - 同一性には、全フィールドを比較するのではなく、明示的で安定したフィールドを使います。 リスト内で使う値型やマップのキーになる値型には、
final String id(または型付き ID)を持たせます。すべてのフィールドから同一性を導くと、値がたまたま同じ別々のエンティティ(「Draft」という名前の2つのItemなど)が1つにまとめられてしまいます。 - 不正な状態を表現できないようにします。 識別子付きの選択肢は、null 可能フィールドの寄せ集め(常に1つだけ設定される形)ではなく、シールドされたバリアントまたは enum をキーとするユニオンとして表現します。不正な状態を型で表現できなければ、どの分岐でもそれを防ぐ必要はありません。
- ドメイン関数は全域的にし、例外を投げてはいけません。 すべての純粋関数は、すべての入力に対して値を返します。不確実性は、明示的な出力(クランプされた値、「N ステップ以内に結果なし」という結果、信頼度が低いことを示すフラグなど)にします。プログラマーの不変条件には
assert(リリース時には取り除かれます)を使い、throwは使いません。回復可能な I/O エラーには型付きの結果を返します。error-handling-typed-resultsを参照してください。 - ごまかしは禁止です。null 可能性を避けるための
late、重要な値への!、場当たり的なモデルとしてのdynamic/Map<String, dynamic>は使いません。 これらは、型システムが本来処理を強制する実行時エラーを隠します。null には?./??/ 型の昇格を使い、構造化データには型付きモデルを使ってください。 - Effective Dart の命名規則をそのまま適用し、定数には
lowerCamelCaseを使います。 型/拡張/列挙型にはUpperCamelCase、ファイル/ディレクトリー/インポート接頭辞にはlowercase_with_underscores、変数/パラメーター/メソッド/定数にはlowerCamelCase(MAX_ITEMSではなくmaxItems)を使います。3文字を超える頭字語は単語として先頭を大文字にします(JSONMapではなくJsonMap、HttpClient)。ファイル名は、そのファイルの主要な宣言名に合わせます。 - 複雑さの上限は確固たるデフォルトとして守ります。この表をライブラリーの唯一の基準とし、他のスキルでは数値を再掲せず、ここを参照します。 メソッドは約30行以下、
build()は約80行以下、ファイルは約300行以下、クラスのパブリック API はメンバー約10個以下、位置引数は3個以下、ロジックのネストは3段階以下です。ウィジェットのビルドツリーでは、より深いネストが妥当な場合があります。ウィジェットのビルド時のネストは5段階以下が明示的な唯一の例外です(widget-compositionを参照)。リファクタリングのきっかけとして活用し、絶対的なルールとはしません。一貫性のある理由で上限を超える場合は(1つのステートマシンを5つの断片に分散させる方が悪いなど)、PR でその理由を示せば許容されます。 - 不変の値型を優先し、単純なものは手書きし、定型コードが多くなる場合は
freezedを使います。 単純な不変値型(フィールド1〜3個)には、@immutable+constコンストラクター +finalフィールドを手書きします(マップのキーにする場合は手動で==/Object.hashも実装します)。copyWith/==/hashCode/ シールドユニオンの定型コードが煩雑になるドメイン値型または UI 状態値型には、freezedの使用を認め、デフォルトとします。*.freezed.dartは第一級の生成アーティファクトです。equatableは使いません(==+Object.hashの5行で十分です)。fpdart/dartzも使いません(Eitherは網羅性を損ないます)。また、--enable-experimentフラグも使いません(そのフラグが削除された日から、メンテナンスされなくなったリポジトリーはビルドできなくなります)。
Sealed クラス:基盤となるイディオム
個別に処理できるケースの閉じた集合は、1つのファイル内の sealed 階層としてモデル化します。sealed ではすべてのサブタイプが同じライブラリ内に存在する必要があります。ファイルが閉じた集合そのものであり、コンパイラーがそれを強制します。
@immutable
sealed class SyncOutcome {
const SyncOutcome();
}
final class SyncApplied extends SyncOutcome {
const SyncApplied(this.appliedCount);
final int appliedCount;
}
final class SyncSkipped extends SyncOutcome {
const SyncSkipped(this.reason);
final String reason;
}
default: なしで switch します。そうすればバリアントを追加したとき、すべての箇所で処理するまでコンパイルエラーになります。
// RIGHT — exhaustive, no wildcard.
switch (outcome) {
case SyncApplied(:final appliedCount):
log.info('applied $appliedCount');
case SyncSkipped(:final reason):
log.warning('skipped: $reason');
}
分岐が抜けていると、dart analyze では non_exhaustive_switch_statement として報告され、dart compile も失敗します。決して抑制しないでください。アナライザーの診断を抑制しても AOT ビルドは失敗するため、CI では解析だけでなくビルドも実行する必要があります。
クラス修飾子:3つを使い分ける
| 意図 | 宣言 |
|---|---|
| コンパイラーによる網羅性チェックが必要な、バリアントの閉じた集合 | sealed class(暗黙的に抽象であり、同じライブラリの外ではインスタンス化も実装もできません) |
| 具象的なリーフ(バリアント、サービス実装、値型) | final class |
| テスト用フェイクが満たすべき境界 | abstract interface class |
すべての具象型は、デフォルトで final class にします。拡張と実装の両方を防ぐため、サブクラスが動作の一部を暗黙に継承することも、テストで具象クラスを誤って implements してフィールドを引き継ぐこともありません。abstract interface class は「継承せず、実装してください」という意味であり、フェイクが求める契約にぴったりです。単独の abstract class は extends を許可します。サービスインターフェースに継承されたデフォルト実装があると、フェイクが気づかないうちに実際の実装を呼び出すことになります。base は使わないでください(単一パッケージのアプリには存在しない、ライブラリ境界の不変条件を強制するものです)。extension type も使わないでください(明示的に安全でない抽象化であり、表現に引き続きアクセスできます)。
レコード:使いどころとコスト
単一レイヤー内で一時的に複数の値を返す場合は、レコードを使います。ローカル座標を表す (int row, int col) は理想的です。構造的同値性が自動で得られ、クラスもコード生成も不要です。レコードが数行以上にわたって使われるなら、フィールドに名前を付けます。({int row, int col}) なら使用箇所で意味が分かりますが、(int, int) の入れ替えミスはコンパイラーでは検出できません。
// Intra-layer only. Two same-typed positional fields = a latent swap bug.
({int page, int size}) nextPage(({int page, int size}) current) =>
(page: current.page + 1, size: current.size);
クラスにするかどうかの判断を避けるために、レコードの形を typedef で定義しないでください。複数のファイルで使う名前付きの形には、クラスが適しています。
Enum か sealed か
集合が閉じていて、すべてのメンバーがペイロードを持たない場合(表示カテゴリやモードなど)は、まず enum を使います。Enum でも switch のコンパイラーによる網羅性チェックが得られ、メンバーごとの記述は1行で済みます。いずれかのメンバーにフィールドが必要になった時点で、sealed と final class に切り替えます。
enum OrderStatus { draft, submitted, shipped, cancelled }
// A member needs data -> sealed, not an enum with parallel nullable fields.
@immutable
sealed class Payment {
const Payment();
}
final class Cash extends Payment { const Cash(); }
final class Card extends Payment {
const Card(this.last4);
final String last4;
}
Switch 式と if-case
すべての分岐が値を生成し、文本体を持たない場合は、switch 式を優先します。構造上網羅的であり、フォールスルーも起こりません。
final label = switch (status) {
OrderStatus.draft => 'Draft',
OrderStatus.submitted => 'Submitted',
OrderStatus.shipped => 'Shipped',
OrderStatus.cancelled => 'Cancelled',
};
分岐で副作用を実行する場合は、switch 文を使います。void の受け皿を使って、副作用を無理に式に押し込まないでください。switch にすると実際の分岐が1つと使われない分岐が1つになるような、単一の興味深いパターンには if (x case Pattern) を使います。ただし、sealed 型に対して if-case を決して使わないでください。switch なら検出できたはずの非網羅性の抜け穴が、気づかないうちに再び生じます。
if (settings.retry case final RetryPolicy p when p.isEnabled) {
await scheduler.arm(p);
}
不変な値型と同一性
4行でコードジェネレーターを置き換えられます。==/hashCode を手書きするのは、その型がマップのキーまたはセットのメンバーになる場合だけにしてください。リスト内での同一性には、安定した id を持たせます。
@immutable
final class Task {
const Task({required this.id, required this.title, required this.done});
final String id; // identity — NOT derived from title/done
final String title;
final bool done;
Task copyWith({String? title, bool? done}) =>
Task(id: id, title: title ?? this.title, done: done ?? this.done);
@override
bool operator ==(Object other) =>
other is Task && other.id == id && other.title == title && other.done == done;
@override
int get hashCode => Object.hash(id, title, done);
}
全域的なドメイン関数
純粋関数は、すべての入力に対して値を返します。不確実性は値で表し、プログラマーの不変条件には assert を使います。
/// Total: clamps rather than throwing on an out-of-range page.
int clampPage(int requested, int pageCount) {
assert(pageCount >= 1, 'pageCount must be positive'); // programmer invariant
if (requested < 0) return 0;
if (requested >= pageCount) return pageCount - 1;
return requested;
}
アンチパターン
- シールド型または列挙型に対する
default:またはcase _:— 網羅性が失われ、新しいバリアントが追加されてもコンパイル時に失敗せず、実行時にフォールスルーします。 - シールド型に対する
if (x case SomeVariant())—switchなら検出できる非網羅性の穴が再び生じます。 - コンストラクターと、一部のメンバーにしか適用されない nullable フィールドを持つ
enum— アクセスのたびに null チェックが必要になり、コンパイラーも推論できません。sealedとfinal classを使ってください。 - リポジトリーから返され、保存されたり、ウィジェットのコンストラクターをまたいで渡されたりするレコード — ドメイン型として名前のない位置指定の構造を使っています。クラスを作成してください。
- 可変なドメインモデルとして使われる
class Order extends ChangeNotifier— ドメイン値は不変です。ViewModel を通じて変更してください(state-management-riverpodを参照)。 - すべてのフィールドに対する
==から導かれる ID — 値が同じエンティティが一つにまとめられてしまいます。安定したidを保持してください。 - null を回避するための
late Foo foo;/foo!.value、モデルとしてのMap<String, dynamic>— 実行時エラーを隠します。?./??/型の昇格と、型付きモデルを使ってください。 - 通常の結果を示すために純粋なドメイン関数から
throwすること — 結果を返してください。assertはプログラマーが保証すべき不変条件に限って使います。 const MAX_ITEMS/class JSONModel/final s = 3;— SCREAMING_CAPS、誤った大文字小文字の頭字語、省略形は使わず、maxItems、JsonModel、省略しない語を使ってください。Widget _buildHeader() => ...— 親と一緒に再ビルドされ、constにできません。constウィジェットクラスに切り出してください(widget-compositionを参照)。equatable/fpdart/dartzや--enable-experimentフラグを追加すること — 不要な依存や、網羅性を失わせるEitherを持ち込むことになります。4 行を手書きするか、ボイラープレートが実際に支配的な場合はfreezedを検討してください。
完了の定義
- シールド型または列挙型に対するすべての
switchが網羅的で、default:/case _:がありません。 - 具体型は
final class、接続点はabstract interface class、閉じた集合はsealed/enumです。base/extension type/実験フラグはありません。 - アクションを持つ閉じた集合は
sealedとfinal class、ペイロードを持たない集合はenumです。nullable フィールドでタグ付けした enum はありません。 - レコードはレイヤー内にとどまり、境界をまたぐ構造はすべて名前付きクラスです。
- ドメイン値は不変です(
finalフィールド、可能な場合はconstコンストラクター、copyWith)。ID は全フィールドの等価性ではなく、安定したidです。 - 不正な状態は表現できません(nullable な値の寄せ集めではなく、sealed/enum の共用体を使います)。
- ドメイン関数は全域的です。不確実性は返し、例外は投げません。不変条件には
assertを使います。 - 重要な値に対して、
late/!/dynamic/Map<String,dynamic>で安全性を取り繕っていません。 - Effective Dart の命名規則をそのまま適用しています。定数は
lowerCamelCase、ファイル名は主要な宣言に合わせています。 - 複雑さの上限が守られているか、一貫性のある超過について PR で正当化されています。
-
scripts/check-dart3-idioms.shが成功し、dart analyzeの警告がありません。
関連スキル
error-handling-typed-results— これらのシールド型の仕組みに基づく Result/Failure の中核とグローバルなエラーネットです。naming-conventions— レイヤーを表す役割サフィックス(Screen/Notifier/Repository/Service/Failure)の命名規則です。state-management-riverpod— 可変状態の配置場所です(不変状態を扱う Notifier ViewModel)。ここで扱うドメイン値は不変のままです。widget-composition—_buildX()メソッドではなく、constウィジェットクラスに切り出します。dart3-idioms-and-coding-standardsは、CI でlint-and-style-configとci-pipeline-and-gatesによって適用されます。
参考資料
- Dart チーム。Effective Dart(Style/Documentation/Usage/Design)。https://dart.dev/effective-dart
- Dart チーム。クラス修飾子。https://dart.dev/language/class-modifiers
- Dart チーム。パターンとパターン型(分解、
if-case)。https://dart.dev/language/patterns - Dart チーム。分岐 — 網羅性。https://dart.dev/language/branches#exhaustiveness-checking
- Dart チーム。レコード。https://dart.dev/language/records
- Dart チーム。健全な null 安全性。https://dart.dev/null-safety