プリミティブ強迫からの脱却:Extension Types とパターンマッチングで到達する「ゼロコスト・ドメインモデル」の深淵
Flutter/Dart エンジニアの諸君、今日も「ただの String」や「ただの int」を関数の引数に引き回し、実行時の型エラーや ID の取り違えに怯えてはいないだろうか?
「UserId なのか OrderId なのか区別がつかないが、ラップクラスを作るとインスタンス化のオーバーヘッドと GC 負荷が気になる……」
そんな妥協は Dart 3.3 で過去のものとなった。今回は、Dart VM の内部動作を熟知した者だけが扱える、Extension Types と Pattern Matching を組み合わせた「ゼロコスト・ドメインモデル」の極致を伝授する。
—
1. なぜ「クラスによるラップ」は敗北するのか
ドメイン駆動設計(DDD)において、値オブジェクト(Value Object)を作ることは鉄則だ。しかし、従来の `class` や `final class` によるラップには、ランタイムにおける「代償」が伴う。
1. メモリオーバーヘッド: 小さな `int` や `String` を保持するためだけに、ヒープ上にオブジェクトが割り当てられ、ポインタのインダイレクションが発生する。
2. GC への負荷: 大量のエンティティをリストで扱う際、これらラップオブジェクトの生成と破棄はガベージコレクションの頻度を上げる。
3. 等価性評価のコスト: `operator ==` や `hashCode` のオーバーライドが必要になり、実行時の計算コストが増大する。
Dart VM は効率的だが、数万個の ID を処理する際にこの差は無視できない。ここで登場するのが Extension Types だ。
—
2. Extension Types:コンパイル時の魔法、実行時の虚像
Extension Types は、コンパイル時には「新しい型」として振る舞うが、実行時にはラップしている下底型(Underlying Type)そのものに完全に消去(Erasure)される。
// コンパイル時には独自の型、実行時にはただの String
extension type const UserId(String value) {}
void main() {
const id = UserId(‘user_123’);
// 実行時にはポインタの参照先を追う必要すらない
// Dart VM はこれを単なる String としてレジスタに載せる
print(id is String); // true
}
これは C++ の `newtype` や Rust の `tuple structs` に近い概念だが、Dart のそれはより「型システム上の制約」に特化している。メソッドの隠蔽やインターフェースの制限が、コンパイル時の静的解析によってのみ保証されるのだ。
—
3. 実践:パターンマッチングと組み合わせた堅牢なドメインモデル
単に型を分けるだけでは不十分だ。そのドメインモデルが「どのような状態にあるか」を、パターンマッチングを用いて安全かつ宣言的に抽出できなければならない。
以下のコードは、API から返却される「未検証の入力」を、型安全なドメインモデルへと昇華させ、パターンマッチングで処理する最高峰の設計パターンである。
コピペで動き、かつ保守性の高いプロダクション例
/// 1. ドメイン固有の型を Extension Types で定義
/// 実行時のオーバーヘッドは 0。AOTコンパイル後はただの String/int になる。
extension type const EmailAddress._(String value) {
// ファクトリによるバリデーション強制
static EmailAddress? tryParse(String raw) {
if (raw.contains(‘@’) && raw.length > 5) {
return EmailAddress._(raw);
}
return null;
}
// 内部の値にアクセスするプロパティ
String get domain => value.split(‘@’).last;
}
extension type const AccountId(int value) implements Object {}
/// 2. ドメインモデルの状態を表現する Sealed Class
sealed class AccountStatus {
const AccountStatus();
}
class Unverified extends AccountStatus {
final EmailAddress email;
const Unverified(this.email);
}
class Verified extends AccountStatus {
final AccountId id;
final EmailAddress email;
const Verified(this.id, this.email);
}
/// 3. パターンマッチングを駆使したビジネスロジック
/// ここでは「網羅性チェック」と「Extension Type の恩恵」が融合する
void processAccount(AccountStatus status) {
final message = switch (status) {
// Extension Type により、email.value ではなく email.domain といった
// 固有のドメインロジックに安全にアクセスできる
Unverified(:final email) =>
‘認証待ち: ${email.domain} ドメインのメールを確認してください。’,
// パターンマッチングによる構造分解。
// id は Extension Type だが、実行時には単なる int として効率的に比較される。
Verified(id: AccountId(value: idValue), :final email) =>
‘認証済みユーザー(ID: $idValue): ${email.value}’,
};
print(message);
}
void main() {
final rawEmail = “architect@example.com”;
final email = EmailAddress.tryParse(rawEmail);
if (email case final EmailAddress validated) {
// 正常系:認証済みとして処理
final user = Verified(AccountId(1024), validated);
processAccount(user);
} else {
print(“不正なメールアドレスです。”);
}
}
—
4. テクニカルリードの視点:なぜこれが「美しい」のか
型の消去(Type Erasure)を味方につける
`AccountId(1024)` と記述しているが、Dart VM は `1024` という即値を扱うのと同等の命令セットを生成する。従来の `class AccountId` では、`new` 命令、ヒープ確保、コンストラクタ実行というステップが必要だったが、Extension Types はこれらをすべてバイパスする。
網羅性の保証
`switch` 文において `AccountStatus` が `sealed` であるため、コンパイラはすべての状態が処理されているかを厳密にチェックする。将来 `Suspended` 状態が追加された際、実装漏れがあればコンパイルエラーが発生する。これは大規模開発において「バグの混入を物理的に不可能にする」設計だ。
パターンマッチングでの「ガード句」と「構造分解」
`email case final EmailAddress validated` という記述(if-case 文)に注目してほしい。これは単なる null チェックを超え、「型が適合し、かつ構造が一致する場合のみスコープを展開する」という強力なセマンティクスを持つ。
—
5. 運用上の注意点:落とし穴を回避せよ
万能に見える Extension Types にも、アーキテクトとして留意すべき点がある。
1. ランタイムの型チェックの限界:
実行時には下底型に消去されるため、`dynamic` 型を経由したキャストには無力だ。
dynamic raw = “not an email”;
final email = raw as EmailAddress; // 実行時には String なのでパスしてしまう!
これを防ぐため、常に `tryParse` のような静的な入り口を設け、`dynamic` や `Object` からの直接キャストを避ける運用ルールを徹底せよ。
2. implements の取捨選択:
`implements String` と書けば String の全メソッドを継承できるが、ドメインモデルとしては「不適切なメソッド(例: `toLowerCase()`)」まで露出してしまう可能性がある。必要なメソッドだけをラップして公開するのが、カプセル化の真髄だ。
—
結論
Dart 3 以降の世界では、「安全性は欲しいがパフォーマンスは妥協したくない」というジレンマは解消された。
Extension Types でドメインの境界を定義し、Sealed Classes で状態を定義し、Pattern Matching でロジックを記述する。この三位一体の設計こそが、現代の Dart 開発における正解である。
もし君のプロジェクトで、まだ `String userId` を関数の引数に使っている箇所があるなら、今すぐこのパターンにリファクタリングすべきだ。それは単なる美学ではなく、実行効率と保守性を極限まで高めるための、プロフェッショナルとしての選択である。