mcpField
D
dart3-idioms-and-coding-standards

dart3-idioms-and-coding-standards

github.com/zakariaf開発63158 回インストール5 週間前に更新
リポジトリ

dart3-idioms-and-coding-standards について

型設計、不変性、命名、全域的なドメイン関数に関する Dart 3 のコーディングスキルです。網羅的な分岐をコンパイラーで確認し、一貫したコード構成を目指す開発者向けです。

主な機能

  1. 01sealed 型の網羅的な switch
  2. 02クラス修飾子と enum の指針
  3. 03不変値型と安定した識別子
  4. 04全域的で例外を投げないドメインロジック
  5. 05Dart の命名規則と複雑度制限
  6. 06Dart 3 慣用表現の検査スクリプト

活用シーン

  1. 01閉じたドメイン結果のモデリング
  2. 02アプリ層をまたぐ不変値の設計
  3. 03switch のバリアント漏れ確認
  4. 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.dart
sealed_outcome.dart
total_domain_function.dart
references/
complexity-and-honesty.md
construct-verdict-table.md
immutability-and-equality.md
scripts/
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 を実行してください。

絶対に守るべきルール

  1. シールド型または列挙型の switch に default: や case _: を含めてはいけません。 ワイルドカードを使うと、スイッチが永遠にコンパイル可能になり、その型が存在する唯一の理由であるコンパイル時の保証が失われます。つまり、バリアントを追加しても実行時に黙ってフォールスルーします。網羅性こそがこの仕組みのすべてです。
  2. クラス修飾子は、次の3つだけを使います。それ以外は無視してください。 コンパイラーに網羅性を検証させる閉じたバリアント集合には sealed class、すべての具象リーフには final class、テスト用フェイクが実装する境界には abstract interface class を使います。具象型はデフォルトで final にします。base、extension type、プライマリーコンストラクター、マクロは使いません。
  3. それぞれ個別に処理できるケースからなる閉じた集合は sealed 階層で、ペイロードのない閉じた集合は enum でモデル化します。 まず enum を使い、いずれかのメンバーにフィールドが必要になった時点で sealed + final class に変更します。一部のメンバーにだけ適用されるデータのために、列挙型へ nullable フィールドを追加してはいけません。そうすると、コンパイラーが推論できない null チェックが、あらゆるアクセスで必要になります。
  4. レコードをレイヤーの境界を越えて渡してはいけません。 レコードは名前もなく、位置で識別され、ドキュメント化もされていない形状です。1つのレイヤー内で一時的に複数の値を返す用途には適しています。リポジトリーから返す、保存する、またはウィジェットのコンストラクターに渡す形状は、名前付きクラスにします。
  5. ドメイン値は不変にします。つまり、final フィールド、可能な場合は const コンストラクター、値の等価性、派生値を作る copyWith を使います。 ウィジェットに渡した値を変更すると、リビルドやゴールデンテストが破綻します。アナライザーが許す限り、final のローカル変数と const コンストラクターを優先してください。
  6. 同一性には、全フィールドを比較するのではなく、明示的で安定したフィールドを使います。 リスト内で使う値型やマップのキーになる値型には、final String id(または型付き ID)を持たせます。すべてのフィールドから同一性を導くと、値がたまたま同じ別々のエンティティ(「Draft」という名前の2つの Item など)が1つにまとめられてしまいます。
  7. 不正な状態を表現できないようにします。 識別子付きの選択肢は、null 可能フィールドの寄せ集め(常に1つだけ設定される形)ではなく、シールドされたバリアントまたは enum をキーとするユニオンとして表現します。不正な状態を型で表現できなければ、どの分岐でもそれを防ぐ必要はありません。
  8. ドメイン関数は全域的にし、例外を投げてはいけません。 すべての純粋関数は、すべての入力に対して値を返します。不確実性は、明示的な出力(クランプされた値、「N ステップ以内に結果なし」という結果、信頼度が低いことを示すフラグなど)にします。プログラマーの不変条件には assert(リリース時には取り除かれます)を使い、throw は使いません。回復可能な I/O エラーには型付きの結果を返します。error-handling-typed-results を参照してください。
  9. ごまかしは禁止です。null 可能性を避けるための late、重要な値への !、場当たり的なモデルとしての dynamic / Map<String, dynamic> は使いません。 これらは、型システムが本来処理を強制する実行時エラーを隠します。null には ?. / ?? / 型の昇格を使い、構造化データには型付きモデルを使ってください。
  10. Effective Dart の命名規則をそのまま適用し、定数には lowerCamelCase を使います。 型/拡張/列挙型には UpperCamelCase、ファイル/ディレクトリー/インポート接頭辞には lowercase_with_underscores、変数/パラメーター/メソッド/定数には lowerCamelCase(MAX_ITEMS ではなく maxItems)を使います。3文字を超える頭字語は単語として先頭を大文字にします(JSONMap ではなく JsonMap、HttpClient)。ファイル名は、そのファイルの主要な宣言名に合わせます。
  11. 複雑さの上限は確固たるデフォルトとして守ります。この表をライブラリーの唯一の基準とし、他のスキルでは数値を再掲せず、ここを参照します。 メソッドは約30行以下、build() は約80行以下、ファイルは約300行以下、クラスのパブリック API はメンバー約10個以下、位置引数は3個以下、ロジックのネストは3段階以下です。ウィジェットのビルドツリーでは、より深いネストが妥当な場合があります。ウィジェットのビルド時のネストは5段階以下が明示的な唯一の例外です(widget-composition を参照)。リファクタリングのきっかけとして活用し、絶対的なルールとはしません。一貫性のある理由で上限を超える場合は(1つのステートマシンを5つの断片に分散させる方が悪いなど)、PR でその理由を示せば許容されます。
  12. 不変の値型を優先し、単純なものは手書きし、定型コードが多くなる場合は 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 によって適用されます。

参考資料

コメント