開発チームの皆さん、お疲れ様です。テクニカルリードの私だ。
コードレビューをしていると、プリミティブ型(`String`や`int`)の変数が氾濫し、関数の引数が「ただの文字列」「ただの数値」の海に溺れているコードに直面することがよくある。`userId`を渡すべきところに`tenantId`を渡し、コンパイルエラーにも気づかず本番障害を引き起こす……。これを防ぐために従来のクラスでラップ(`class UserId { final String value; … }`)すると、今度はインスタンス生成のオーバーヘッドやGC(ガベージコレクション)への負荷、アロケータを圧迫するコストに悩まされることになる。
「型安全性」と「ゼロコスト・アブストラクション」。この二律背反をDart 3の `extension types`(拡張型) と パターンマッチング を組み合わせて完全にハックする方法を伝授しよう。
ネット上の浅い入門記事を読むのは終わりにしてほしい。今回は、Dart VMの内部挙動まで踏み込んだ、プロダクションで即座に使える堅牢なアーキテクチャを解説する。
—
なぜ従来のラッパクラスは悪手なのか?
まずは敵を知ることから始めよう。よくある「プリミティブ執着」を脱却するためのクラスベースのラッパだ。
// 従来のクラスベース(アンチパターン)
class UserId {
final String value;
const UserId(this.value);
}
このアプローチは型安全だが、実行時にヒープアロケーション(Heap Allocation)が発生する。数千件のAPIレスポンスをパースする際、すべてのIDをこのようなクラスで包むと、Dart VMのジェネレーショングラッドGCに深刻なプレッシャーを与える。特にFlutterのUIスレッドでこれをやると、Jank(カクつき)の原因になる。
救世主:`extension type` の本質
Dart 3で導入された `extension type` は、「実行時は生データ、コンパイル時は厳格な型」という究極のトレードオフを実現する。
// 実行時は単なる String として扱われるゼロコスト抽象
extension type const UserId(String value) {
// バリデーションやドメインロジックを持たせることができる
bool get isValid => value.isNotEmpty && value.startsWith(‘usr_’);
}
Dart VMの裏側の動き:
コンパイル後、`UserId` 型の変数は、AOT/JITコンパイラによってその裏にある基底型(この場合は `String`)に消去(Erasure)される。つまり、メモリ上にはただのStringしか存在せず、オブジェクト生成コストはゼロだ。
—
パターンマッチングとの融合:堅牢なドメインモデルの設計
ここからが本題だ。APIから飛んできた不確実なJSONデータを、`extension type` と Dart 3の `switch` 式・パターンマッチングを駆使して、型安全なドメインモデルへと昇華させる。
以下のプロダクションコードを見てほしい。ECサイトの「注文ステータス」と「ユーザー識別子」を題材にした、実務でそのまま使える設計パターンだ。
// ==========================================
// 1. プリミティブ執着を断つ Extension Types
// ==========================================
extension type const UserId(String value) {
// コンパイル時保証された型に対してドメインルールを定義
String get masked => ‘${value.substring(0, 4)}‘;
}
extension type const OrderId(String value) {}
// ==========================================
// 2. ドメインの表現(代数的データ型: ADT 的アプローチ)
// ==========================================
sealed class OrderState {
const OrderState();
}
class Pending extends OrderState {
const Pending();
}
class Paid extends OrderState {
final DateTime paidAt;
const Paid(this.paidAt);
}
class Shipped extends OrderState {
final String trackingNumber;
const Shipped(this.trackingNumber);
}
class Canceled extends OrderState {
final String reason;
const Canceled(this.reason);
}
// ==========================================
// 3. APIレスポンスを表現するエンティティ
// ==========================================
class Order {
final OrderId id;
final UserId userId;
final OrderState state;
const Order({
required this.id,
required this.userId,
required this.state,
});
// JSONからの安全なファクトリコンストラクタ
factory Order.fromJson(Map
// ここで強制的に Extension Type に包む(実行時オーバーヘッドゼロ)
final orderId = OrderId(json[‘order_id’] as String);
final userId = UserId(json[‘user_id’] as String);
OrderState state = switch (json[‘status’] as String) {
‘pending’ => const Pending(),
‘paid’ => Paid(DateTime.parse(json[‘paid_at’] as String)),
‘shipped’ => Shipped(json[‘tracking_number’] as String),
‘canceled’ => Canceled(json[‘reason’] as String),
// 未知のステータスが来たらコンパイル時ではなくとも実行時安全にフォールバック
_ => throw FormatException(‘Unknown order status: ${json[‘status’]}’),
};
return Order(id: orderId, userId: userId, state: state);
}
}
// ==========================================
// 4. パターンマッチングによる完全網羅なビジネスロジック
// ==========================================
String renderOrderDescription(Order order) {
// Dart 3 の switch expression とパターンマッチングの組み合わせ
// sealed class なので、全ケースを網羅しないとコンパイルエラーになる
return switch (order.state) {
Pending() => ‘注文 ${order.id.value} は現在支払い待ちです。’,
Paid(:final paidAt) => ‘注文 ${order.id.value} は ${paidAt} に支払われました。’,
Shipped(:final trackingNumber) => ‘注文 ${order.id.value} は発送済みです。追跡番号: $trackingNumber’,
Canceled(:final reason) => ‘注文 ${order.id.value} はキャンセルされました(理由: $reason)。’,
};
}
// ==========================================
// 5. 実行エントリポイント
// ==========================================
void main() {
// サーバから受け取った生データのシミュレーション
final rawJson = {
‘order_id’: ‘ord_998877’,
‘user_id’: ‘usr_123456’,
‘status’: ‘shipped’,
‘tracking_number’: ‘JP-987654321’,
};
try {
final order = Order.fromJson(rawJson);
// ユーザーIDの型安全性を検証(異なる型同士の代入はコンパイルエラーになる)
// printUserId(order.orderId); // ← コンパイルエラー! UserId を要求する場所に OrderId は渡せない
print(‘— 注文詳細 —‘);
print(‘ユーザー: ${order.userId.masked}’); // 出力: usr_
print(renderOrderDescription(order));
} catch (e) {
print(‘エラー発生: $e’);
}
}
—
この設計がプロダクションで最強である理由
コードレビューで「なぜこのように書くべきか」を問われた際、以下の3点を論拠として提示してほしい。
1. ゼロコストによるパフォーマンスの担保
`UserId` や `OrderId` は `extension type` であるため、前述の通りメモリ上にクラスインスタンスが生成されない。大量のリストデータを扱うフロントエンド(Flutterの `ListView.builder` やWebアプリの巨大なテーブル)において、GCのスパイクを防ぎながらドメインモデルの型安全性を100%担保できる。
2. コンパイル時検査による「バグの芽の摘み取り」
例えば、関数 `void sendNotification(UserId userId, OrderId orderId)` があったとする。
これを従来の `String` や `int` で実装していると、引数を逆に渡してもコンパイラは何も言わない。しかし、`extension type` を使っていれば、型が異なるだけでコンパイルエラーになる。人間がうっかり間違える余地をコンパイラに肩代わりさせるのだ。
3. パターンマッチングの網羅性(Exhaustiveness Checking)
`sealed class` と Dart 3 の `switch` 式を組み合わせることで、将来的に `OrderState` に新しい状態(例: `Refunded`)が追加された際、その状態を処理し忘れているすべての `switch` 式がコンパイルエラーとして検知される。これにより、「仕様変更に伴うデグレード」を完全に防ぐことができる。
—
現場で気をつけるべきアンチパターン
最後に、`extension type` を使う上での重要な注意点を述べておく。
- 表現型のすり替え(Immutabilityの破壊を避ける)
`extension type` はあくまで基底型のラッパーであり、ミュータブルな操作を隠蔽するべきではない。基本的には `final` なプロパティを持ち、不変(Immutable)なデータモデルとして設計すること。
- 過剰なネストの禁止
`extension type` の中にさらに別の `extension type` を何層も重ねると、コードの可読性が下がり、IDEの補完が重くなる原因になる。ドメインの境界(API境界や永続化層)でのみラップするのが最も美しい。
結びに
Dart 3のポテンシャルは、ただの「モダンな構文の追加」にとどまらない。コンパイラの挙動を理解し、言語の機能と型システムを正しく設計に落とし込むことで、「堅牢性」と「極限のパフォーマンス」の二兎を同時に追うことが可能になる。
次回のコードレビューでは、生きた `String` や `int` がドメインロジックの引数にそのまま露出していないか、厳しくチェックしてほしい。あなたの書くコードが、チーム全体の品質を引き上げる礎となるはずだ。