【実務・中級編】Dartの「typedef」と「レコード型」を組み合わせて、関数シグネチャを型安全に定義する – Dart コア文法・オブジェクト指向・Null安全解析バイブル

Dart 3の真骨頂:レコード型と`typedef`で関数シグネチャを極限まで型安全にする設計手法

コードレビューをしていて、次のような関数シグネチャに出くわしたことはないでしょうか?

// 良くあるアンチパターン
Future> fetchUsers(String query, int limit, bool includeDeleted, SortOrder order) async { … }

引数が4つを超えたあたりから、呼び出し側で順番を間違えるバグ(Primitive Obsession)が頻発します。さらに、コールバック関数やイベントハンドラーが複雑なパラメータを取るようになると、コードベースは一気に「読むのが苦痛なレガシー」へと堕ちていきます。

Dart 3で導入された「レコード型 (Records)」と、古くからある「`typedef`」。この2つを組み合わせることで、クラスを乱立させることなく、極限まで型安全かつ意図が明確な関数シグネチャを設計できます。

今回は、Dart VMの内部挙動や型システムの制約を踏まえ、プロダクションコードで即座に使える洗練された設計パターンをテクニカルリードの視点から伝授します。

—

なぜクラスではなく「レコード型 + typedef」なのか?

実務の現場では、「構造体的なデータを扱うために毎回 `class` や `freezed` の `@freezed` クラスを作る」ことが行われます。しかし、単一の関数のためだけに名前付きクラスを定義するのは、ボイラープレート(冗長なコード)が増える原因になります。

一方で、素のレコード型(例: `(String, {int limit})`)をそのまま関数シグネチャに書くと、以下の問題が生じます。

1. シグネチャが長くなりすぎて可読性が落ちる
2. 同じ構造のレコードをあちこちに書くことになり、DRY原則に反する

ここで`typedef`の出番です。`typedef`に名前を与えることで、「コンパイル時の厳格な型安全性」と「圧倒的な可読性」を両立できます。

—

プロダクションコードで学ぶ設計パターン

ここでは、Webフロントエンドや非同期API連携を想定し、「検索フィルターの状態を受け取り、データを非同期でフェッチする関数」を例に取ります。

1. 堅牢な型定義と実装

import ‘dart:async’;

// ==========================================
// 1. レコード型を用いたパラメータの構造化とtypedef
// ==========================================

/// ユーザー検索のクエリパラメータを定義するレコード型
typedef UserSearchQuery = ({
String keyword,
int limit,
int offset,
bool includeInactive,
UserSortField sortField,
});

/// ソート順の定義
enum UserSortField { name, createdAt, loginCount }

/// 検索結果のメタデータを含む戻り値のレコード型
typedef UserSearchResult = ({
List users, // 簡略化のため文字列のリスト
int totalCount,
bool hasMore,
});

/// コールバック用のtypedef(エラーハンドリング統合型)
typedef UserSearchCallback = Future Function(UserSearchQuery query);

// ==========================================
// 2. ビジネスロジック(リポジトリ層)
// ==========================================

class UserRepository {
// typedefされたシグネチャを採用することで、引数の順序ミスや渡し忘れを完全にコンパイル時になくす
Future search(UserSearchQuery query) async {
// レコードの分解(Destructuring)によるクリーンなアクセス
// 名前付きフィールドなので、順番を気にする必要は一切ない
final (:keyword, :limit, :offset, :includeInactive, :sortField) = query;

// 擬似的なAPI遅延とクエリ検証
await Future.delayed(const Duration(milliseconds: 300));

if (keyword.isEmpty && limit > 100) {
throw ArgumentError(‘Limit cannot exceed 100 when keyword is empty.’);
}

// モックデータの返却
final mockUsers = [‘Alice’, ‘Bob’, ‘Charlie’]
.where((u) => u.toLowerCase().contains(keyword.toLowerCase()))
.toList();

return (
users: mockUsers,
totalCount: mockUsers.length,
hasMore: false,
);
}
}

// ==========================================
// 3. UI / クライアント層での利用
// ==========================================

void main() async {
final repository = UserRepository();

// 呼び出し側:名前付き引数形式でレコードを構築するため、自己文書化(Self-documenting)される
final query: UserSearchQuery = (
keyword: ‘Dart’,
limit: 10,
offset: 0,
includeInactive: false,
sortField: UserSortField.createdAt,
);

try {
// 実行
final result = await repository.search(query);

// パターンマッチング風の分解代入で結果を受け取る
final (:users, :totalCount, :hasMore) = result;

print(‘Fetched ${users.length} users (Total: $totalCount). Has more: $hasMore’);
} catch (e) {
print(‘Error executing search: $e’);
}
}

—

コードレビューの視点:なぜこの設計が優れているのか?

A. コンパイル時安全性と構造的型付け (Structural Typing)

Dartのレコード型は構造的型付けです。つまり、同じフィールド名と型を持つレコードであれば、異なる `typedef` 名であっても代入互換性を持ちます(※ただし、Dartの `typedef` はエイリアスなので、可読性と言語サーバーの補完を強制するためにエイリアスを通します)。これにより、予期せぬ型キャストのエラー(`TypeError`)が実行時発生する余地を完全にコンパイル時になくします。

B. メモリ効率とパフォーマンスの最適化

「じゃあ、こういうデータ構造は全部 `class` や `freezed` でいいじゃないか」と思われるかもしれません。しかし、`class` はヒープ上にインスタンスが生成され、GC(ガベージコレクション)のプレッシャーになります。

一方、Dartのレコードは軽量な値オブジェクト(Value Object)です。小規模なレコードはインラインで最適化されやすく、不要なオブジェクト生成コストを抑制できます。特にUIの再描画頻度が高いFlutterや、高スループットが求められるサーバーサイドDartにおいて、GCストールを軽減する強力な武器となります。

C. デストラクチャリング(分解)のシナジー

関数内でパラメータを受け取った際、`query.keyword` のようにドットアクセスする必要はありません。

final (:keyword, :limit) = query;

このように一瞬でローカル変数に展開できるため、コードのボイラープレートが劇的に削ぎ落とされます。

—

実務で気をつけるべきアンチパターンと注意点

最後に、シニアエンジニアとしてこのパターンを導入する際の注意点を共有します。

1. レコードの肥大化に注意する
フィールド数が6個、7個と増えていくレコードは、もはや「単なるパラメータの束」ではなく、ドメインモデル(ビジネスロジックを持つべき実体)であるべきです。その場合は潔く `class` や `@freezed` を選択してください。「目安は最大4〜5フィールドまで」です。

2. ミュータビリティの誤解
Dartのレコードはイミュータブル(不変)です。フィールドの値を後から書き換えることはできません。状態を更新したい場合は、レコードの構造展開構文(Record update syntax)を使います。

final updatedQuery = (
…query,
keyword: ‘Flutter’, // keywordだけ上書き
);

—

まとめ

Dart 3のレコード型と`typedef`の組み合わせは、関数シグネチャの「意味不明な引数の羅列」という悪しき習慣を根絶するための特効薬です。

  • 引数の順序ミスをコンパイルエラーで防ぐ
  • 無駄なクラス定義を減らし、コードベースを軽量に保つ
  • デストラクチャリングによって圧倒的な可読性を手に入れる

明日のコードレビューから、多すぎる引数を持つ関数を見つけたら、このパターンへのリファクタリングを提案してみてください。チーム全体のコード品質が一段階上のステージへと引き上げられるはずです。

タイトルとURLをコピーしました