こんにちは!Dartの奥深い世界へようこそ。
フルスタックエンジニアの先輩として、今日から君をさらにワンランク上のDart使いへと導いていきますね。
今回は、Dart 3で導入された革命的な機能である「Extension Types(拡張型)」と「パターンマッチング」を組み合わせた、最高にモダンで型安全なドメインモデルの設計術についてお話しします。
「プリミティブ型をそのまま使うのは怖いけれど、わざわざクラスを作るとメモリの無駄遣い(オーバーヘッド)になりそう……」そんなモヤモヤを抱えていませんか?ここをクリアすれば、君の書くコードは一気にプロダクションクオリティになりますよ。一緒にバッチリマスターしていきましょう!
—
1. なぜドメインモデルに「Extension Types」が必要なのか?
例えば、ユーザーIDやメールアドレスを扱うとき、次のようにただの `String` や `int` で書いていませんか?
// 危険なコードの例
void sendEmail(String userId, String email) {
// …
}
// 呼び出し側:引数の順番を間違えてもコンパイルエラーにならない!
sendEmail(“user@example.com”, “user_12345”);
これだと、引数をうっかり逆に渡してもコンパイルが通ってしまい、実行時エラーの温床になりますよね。これを防ぐために、従来は次のような「ラッパークラス」を作っていました。
class UserId {
final String value;
UserId(this.value);
}
しかし、このアプローチには「インスタンス生成のコスト(メモリ割り当てとGCの負荷)」というパフォーマンス上の代償がありました。数千、数万のオブジェクトを扱うドメイン層では、この小さなオーバーヘッドがバカになりません。
救世主「Extension Types」の正体
ここで登場するのが Dart 3 の Extension Types (`extension type`) です。
// 実行時はただの String として扱われるが、型としては厳格に区別される
extension type UserId(String value) {
// バリデーションなどのメソッドを持たせることも可能
bool get isValid => value.startsWith(‘usr_’);
}
実はこれ、コンパイル時だけの架空のバリケードです。AOT(Ahead-Of-Time)コンパイルやJIT実行時において、`UserId` 型の変数はラップ元の `String` そのものにインライン展開されます。つまり、実行時のメモリオーバーヘッドが「ゼロ」なんです。すごくないですか?
—
2. パターンマッチングで型安全に「解剖」する
Extension Typesでラップされた値は、安全な反面、「元の値を取り出して処理したい」ときに少しボイラープレート(定型コード)が増えがちです。ここで Dart 3 のパターンマッチング(`switch` 式)を組み合わせると、劇的にエレガントになります。
具体的なドメインモデルを使って見ていきましょう。ECサイトの「注文ステータス」を例にします。
// 注文IDの拡張型
extension type OrderId(String id) {}
// 金額の拡張型
extension type Amount(int yen) {
bool get isHighValue => yen >= 10000;
}
// 注文の状態を表現するsealedクラスとExtension Typesの組み合わせ
sealed class OrderState {}
class Unpaid extends OrderState {
final Amount amount;
Unpaid(this.amount);
}
class Paid extends OrderState {
final Amount amount;
final DateTime paidAt;
Paid(this.amount, this.paidAt);
}
class Canceled extends OrderState {}
このモデルに対して、パターンマッチングを使ってビジネスロジック(請求書の文面生成など)を書いてみます。
String generateInvoiceMessage(OrderId orderId, OrderState state) {
// switch 式 によるパターンマッチング
return switch (state) {
// Unpaid の中にある Amount (拡張型) をそのままパターンでキャプチャ
Unpaid(amount: var amt) when amt.isHighValue =>
‘注文 ${orderId.id}: 高額商品です。お支払い(${amt.yen}円)をお急ぎください。’,
Unpaid(amount: var amt) =>
‘注文 ${orderId.id}: お支払い金額は ${amt.yen}円 です。’,
Paid(amount: var amt, :var paidAt) =>
‘注文 ${orderId.id}: ${paidAt.toIso8601String()} に入金を確認しました(${amt.yen}円)。’,
Canceled() =>
‘注文 ${orderId.id} はキャンセルされました。’,
};
}
このコードの何がスゴいのか?
1. 網羅性チェック(Exhaustiveness Checking):
`switch` 式が `OrderState` のすべてのサブクラス(`Unpaid`, `Paid`, `Canceled`)を網羅しているかをDartのコンパイラが厳密にチェックします。将来、新しいステータス(例: `Shipped`)を追加し忘れると、コンパイルエラーで即座に教えてくれます。
2. ゼロコストの型安全:
`OrderId` や `Amount` はメソッドやプロパティ(`isHighValue` など)を持ちながらも、ランタイムにはただの `String` や `int` として高速に処理されます。
—
3. 陥りやすい文法エラーと注意点
初心者のうちは、Extension Typesの強力さゆえに、いくつかハマりやすいポイントがあります。ここで先回りしてクリアにしておきましょう。
⚠️ 注意1: 実行時型チェック(`is` 演算子)の勘違い
Extension Typesはあくまで「コンパイル時の見かけの型」です。そのため、以下のようなコードを書くと、期待とは異なる挙動になります。
OrderId id = OrderId(“usr_123”);
print(id is String); // ➔ 実行時はただのStringなので「true」になる!
print(id is OrderId); // ➔ コンパイル時の型なので、これもtrueだが注意が必要
実行時にジェネリクスやリフレクション的アプローチで `is OrderId` のような厳密なラップ型判定を行おうとすると、基底型(`String`)と区別がつかなくなるため、ビジネスロジックの設計時には「実行時は基底型である」という前提を忘れないようにしましょう。
⚠️ 注意2: 無効なコンストラクタや状態の持ち方
Extension Typesは、基本的に1つのラップ対象(Repr)しか持てません。
// ❌ コンパイルエラー:複数のフィールドは持てない
extension type InvalidModel(String name, int age) {}
// ⭕️ 正解:レコード(Record)を使えば複数持てるが、基本は1つに絞るべき
extension type ValidModel((String, int) data) {}
ドメインモデルのプリミティブをラップするという本来の目的に徹し、1つの値(IDや金額など)に対して付加価値や型安全性を与えるために使いましょう。
—
まとめ
いかがでしたでしょうか?
- Extension Types を使えば、実行時コストを一切かけずに、プリミティブ型にドメイン固有の「意味」と「振る舞い」を持たせられる。
- パターンマッチング (`switch` 式) を組み合わせることで、複雑な状態分岐を安全かつ簡潔に記述できる。
この2つをマスターした君は、もうただの「文法を覚えたプログラマー」ではありません。メモリ効率と保守性を高い次元で両立させるアーキテクトの視点を手に入れました。
明日からのDart開発で、ぜひこのイディオムをガンガン使ってみてくださいね。分からなくなったら、いつでもこの記事に立ち返ってきてください。君のコーディングライフを応援しています!