Lodash を“薄いヘルパー層”として包む:プロジェクト固有ユーティリティの設計指針

Lodash は多くのプロジェクトで採用される優秀なユーティリティライブラリです。しかし、直接 Lodash を呼び出すのではなく、プロジェクト固有の"薄いヘルパー層"で包むことで、コードの保守性・テスト容易性・拡張性が大きく向上します。
この記事では、Lodash を薄いヘルパー層として包む設計指針と実践的な実装パターンを詳しく解説していきますね。
背景
なぜ Lodash を直接呼び出すとメンテナンスが困難になるのか
Lodash は便利な関数を数多く提供していますが、プロジェクト全体に直接 Lodash の関数呼び出しが散在すると、いくつかの問題が発生します。
まず、依存関係の密結合が挙げられるでしょう。アプリケーションコードが Lodash に強く依存してしまうと、将来的にライブラリを変更したい場合や、独自実装に切り替えたい場合に、膨大な修正コストが発生します。
次に、ビジネスロジックの表現力不足です。_.filter(users, { isActive: true })
といったコードは汎用的ですが、プロジェクト固有の意図(「アクティブユーザーを取得する」)が読み取りにくくなるでしょう。
さらに、テストの複雑化も無視できません。Lodash を直接呼び出すコードは、Lodash の動作に依存するため、テストダブルの導入や部分的なモック化が困難になります。
下図は、Lodash を直接呼び出す構造と、薄いヘルパー層を挟む構造の比較です。
mermaidflowchart LR
subgraph direct["直接呼び出し(密結合)"]
app1["アプリケーション"] -->|直接依存| lodash1["Lodash"]
end
subgraph wrapper["ヘルパー層(疎結合)"]
app2["アプリケーション"] -->|ビジネス意図| helper["薄いヘルパー層"]
helper -->|内部実装| lodash2["Lodash"]
end
このように、ヘルパー層を導入することで、アプリケーションコードは Lodash の実装詳細から切り離され、より柔軟な設計が可能になります。
プロジェクト固有のドメイン知識を型安全に表現する必要性
TypeScript を利用するプロジェクトでは、型安全性が重要です。Lodash の汎用的な型定義では、プロジェクト固有のビジネスルールやドメイン知識を十分に表現できないことがあるでしょう。
たとえば、ユーザーデータをフィルタリングする際、「管理者ユーザー」「有効期限内のユーザー」といったドメイン概念を型として明示したいですよね。薄いヘルパー層を設けることで、これらの概念を型安全に表現でき、コンパイル時にバグを検出できます。
また、プロジェクト特有の変換ルール(例:日本の郵便番号フォーマット、特定の日付形式)を一元管理できる点も大きなメリットです。
課題
Lodash 直接利用がもたらす 5 つの技術的負債
Lodash を直接利用し続けると、以下の 5 つの技術的負債が蓄積されます。
# | 技術的負債 | 具体例 | 影響 |
---|---|---|---|
1 | 依存関係の密結合 | コード全体に _.xxx が散在 | ライブラリ変更時の修正コストが膨大 |
2 | ビジネスロジックの不明瞭化 | 汎用的な関数呼び出しが続く | コードの意図が読み取りにくい |
3 | テスト困難性 | Lodash のモック化が必要 | ユニットテストの作成が複雑化 |
4 | 型安全性の低下 | 汎用的な型定義のみ | ドメイン固有のエラーを検出できない |
5 | 拡張性の欠如 | プロジェクト固有の処理を追加しにくい | 同じコードが重複する |
これらの負債は、プロジェクトが成長するにつれて深刻化していきます。
コードの可読性と保守性の低下
Lodash の関数は汎用的であるため、そのままでは「何のためのフィルタリングか」「どういう意図の変換か」が分かりにくいでしょう。
以下のコードを見てみましょう。
typescript// Lodash を直接呼び出す例
const result = _.chain(users)
.filter((u) => u.status === 'active')
.filter((u) => moment(u.expiredAt).isAfter(moment()))
.map((u) => ({ id: u.id, name: u.name }))
.value();
このコードは動作しますが、各フィルタリングの意図が明確ではありません。「active なユーザー」「有効期限内のユーザー」という概念がコード上に表現されていないため、可読性が低下します。
薄いヘルパー層を導入すると、以下のように改善できます。
typescript// ヘルパー層を利用した例
const result = UserHelpers.chain(users)
.filterActive()
.filterNotExpired()
.toSimpleFormat()
.value();
これなら、各処理の意図が一目瞭然ですね。
下図は、コードの可読性と保守性の関係を示しています。
mermaidflowchart TD
start["コード記述"] --> check{"ヘルパー層<br/>使用?"}
check -->|No| direct["Lodash 直接呼び出し"]
check -->|Yes| helper["ヘルパー層経由"]
direct --> low["可読性:低<br/>保守性:低"]
helper --> high["可読性:高<br/>保守性:高"]
low --> debt["技術的負債<br/>蓄積"]
high --> maintain["持続可能な<br/>開発"]
このように、ヘルパー層の有無がコード品質に大きく影響することが分かります。
ライブラリのバージョンアップやマイグレーションの困難さ
Lodash のバージョンアップ時や、別のライブラリへの移行時には、コード全体を検索して修正する必要があります。これは非常に時間がかかり、バグの混入リスクも高いでしょう。
例えば、Lodash v3 から v4 への移行では、いくつかの関数のシグネチャが変更されました。プロジェクト全体に Lodash の呼び出しが散在していると、すべての箇所を見つけて修正しなければなりません。
薄いヘルパー層があれば、修正箇所はヘルパー層の実装のみに限定されます。アプリケーションコードは影響を受けず、安全にマイグレーションできるでしょう。
解決策
"薄いヘルパー層"の設計原則
薄いヘルパー層を設計する際は、以下の原則に従いましょう。
原則 1:単一責任の原則を守る
各ヘルパー関数は、1 つの明確な責任だけを持つべきです。複数の処理を 1 つの関数にまとめてしまうと、再利用性が低下し、テストも困難になります。
良い例:
typescript// users.helper.ts
/**
* アクティブなユーザーのみを抽出します
*/
export function filterActiveUsers(users: User[]): User[] {
return _.filter(users, { status: 'active' });
}
悪い例:
typescript// 複数の責任を持つ関数(非推奨)
export function getActiveUsersWithEmails(
users: User[]
): string[] {
return _.chain(users)
.filter({ status: 'active' })
.map('email')
.value();
}
原則 2:ドメイン用語を関数名に反映する
関数名には、プロジェクト固有のドメイン用語を使用しましょう。これにより、コードの可読性が大幅に向上します。
typescript// users.helper.ts
/**
* 有効期限内のユーザーを抽出します
* 有効期限が設定されていない場合は常に有効とみなします
*/
export function filterNotExpiredUsers(
users: User[]
): User[] {
const now = new Date();
return _.filter(users, (user) => {
if (!user.expiredAt) return true;
return new Date(user.expiredAt) > now;
});
}
filterNotExpired
という関数名は、「有効期限内のユーザー」というドメイン概念を明確に表現していますね。
原則 3:型安全性を最大限に活用する
TypeScript の型システムを活用し、プロジェクト固有の型を定義しましょう。
typescript// users.types.ts
/**
* ユーザーのステータス
*/
export type UserStatus =
| 'active'
| 'inactive'
| 'suspended';
/**
* ユーザーエンティティ
*/
export interface User {
id: string;
name: string;
email: string;
status: UserStatus;
expiredAt?: Date;
createdAt: Date;
}
/**
* 簡易ユーザー情報(表示用)
*/
export interface SimpleUser {
id: string;
name: string;
}
型を明確に定義することで、コンパイル時にエラーを検出できます。
原則 4:内部実装を隠蔽する
ヘルパー層の内部で Lodash を使うか、独自実装を使うかは、外部からは見えないようにするべきです。これにより、将来的な実装変更が容易になります。
typescript// arrays.helper.ts
/**
* 配列から重複を除去します
* @param items - 処理対象の配列
* @returns 重複が除去された新しい配列
*/
export function removeDuplicates<T>(items: T[]): T[] {
// 内部実装は Lodash を使用(外部からは見えない)
return _.uniq(items);
}
将来的に Lodash ではなく標準の Set
を使いたくなった場合でも、この関数の実装だけを変更すればよいでしょう。
下図は、ヘルパー層の設計原則をまとめたものです。
mermaidflowchart TD
start["ヘルパー層設計"] --> principle1["原則1:単一責任"]
start --> principle2["原則2:ドメイン用語"]
start --> principle3["原則3:型安全性"]
start --> principle4["原則4:実装隠蔽"]
principle1 --> benefit1["再利用性向上<br/>テスト容易"]
principle2 --> benefit2["可読性向上<br/>意図明確化"]
principle3 --> benefit3["バグ早期発見<br/>リファクタ安全"]
principle4 --> benefit4["柔軟な変更<br/>疎結合"]
benefit1 --> goal["保守性の高い<br/>コードベース"]
benefit2 --> goal
benefit3 --> goal
benefit4 --> goal
これらの原則を守ることで、長期的に保守しやすいコードベースを構築できます。
プロジェクト固有ヘルパーのディレクトリ構成
ヘルパー層は、以下のようなディレクトリ構成で管理すると良いでしょう。
typescript// プロジェクト構成例
src/
├── helpers/
│ ├── index.ts // すべてのヘルパーをまとめてエクスポート
│ ├── arrays.helper.ts // 配列操作ヘルパー
│ ├── objects.helper.ts // オブジェクト操作ヘルパー
│ ├── users.helper.ts // ユーザー関連ヘルパー
│ ├── dates.helper.ts // 日付操作ヘルパー
│ └── __tests__/ // ヘルパーのテスト
│ ├── arrays.helper.test.ts
│ ├── objects.helper.test.ts
│ ├── users.helper.test.ts
│ └── dates.helper.test.ts
├── types/
│ └── user.types.ts // 型定義
└── ...
このように、機能ごとにファイルを分割し、テストファイルも同じ階層で管理することで、見通しの良いコードベースが実現できます。
エントリーポイント(index.ts)の設計
すべてのヘルパーを一箇所からエクスポートすることで、インポート文がシンプルになります。
typescript// helpers/index.ts
/**
* プロジェクト共通ヘルパー関数のエントリーポイント
* 各ヘルパーモジュールからエクスポートされた関数を再エクスポートします
*/
// 配列操作ヘルパー
export * from './arrays.helper';
// オブジェクト操作ヘルパー
export * from './objects.helper';
// ユーザー関連ヘルパー
export * from './users.helper';
// 日付操作ヘルパー
export * from './dates.helper';
これにより、アプリケーションコードでは以下のようにインポートできます。
typescript// アプリケーションコードでの利用例
import {
filterActiveUsers,
removeDuplicates,
} from '@/helpers';
シンプルで分かりやすいですね。
具体例
配列操作ヘルパーの実装
配列操作は Lodash の得意分野ですが、プロジェクト固有の処理を追加することで、より使いやすくなります。
基本的な配列操作
以下は、配列の重複除去、チャンク分割、グルーピングなどの基本操作をラップした例です。
typescript// helpers/arrays.helper.ts
import _ from 'lodash';
/**
* 配列から重複を除去します
* @param items - 処理対象の配列
* @returns 重複が除去された新しい配列
*/
export function removeDuplicates<T>(items: T[]): T[] {
return _.uniq(items);
}
typescript/**
* 指定されたキーに基づいて配列から重複を除去します
* @param items - 処理対象の配列
* @param key - 重複判定に使用するキー
* @returns 重複が除去された新しい配列
*/
export function removeDuplicatesBy<T>(
items: T[],
key: keyof T
): T[] {
return _.uniqBy(items, key);
}
typescript/**
* 配列を指定されたサイズのチャンクに分割します
* @param items - 処理対象の配列
* @param size - チャンクのサイズ
* @returns チャンク化された配列の配列
*/
export function splitIntoChunks<T>(
items: T[],
size: number
): T[][] {
if (size <= 0) {
throw new Error('Chunk size must be greater than 0');
}
return _.chunk(items, size);
}
typescript/**
* 配列を指定されたキーでグルーピングします
* @param items - 処理対象の配列
* @param key - グルーピングに使用するキー
* @returns キーごとにグルーピングされたオブジェクト
*/
export function groupByKey<T>(
items: T[],
key: keyof T
): Record<string, T[]> {
return _.groupBy(items, key);
}
これらの関数は、Lodash の機能をそのままラップしていますが、プロジェクト固有の型情報とドキュメントコメントを追加することで、使いやすさが向上しています。
プロジェクト固有の配列処理
次に、プロジェクト固有の要件を満たす処理を追加しましょう。
typescript/**
* 配列を安全にソートします(元の配列を変更しません)
* @param items - ソート対象の配列
* @param key - ソートキー
* @param order - ソート順('asc' または 'desc')
* @returns ソートされた新しい配列
*/
export function safeSortBy<T>(
items: T[],
key: keyof T,
order: 'asc' | 'desc' = 'asc'
): T[] {
const sorted = _.sortBy(items, key);
return order === 'desc' ? _.reverse(sorted) : sorted;
}
typescript/**
* 配列から指定された条件に一致する最初の要素を安全に取得します
* 見つからない場合はデフォルト値を返します
* @param items - 検索対象の配列
* @param predicate - 検索条件
* @param defaultValue - 見つからない場合のデフォルト値
* @returns 見つかった要素またはデフォルト値
*/
export function findWithDefault<T>(
items: T[],
predicate: Partial<T>,
defaultValue: T
): T {
return _.find(items, predicate) ?? defaultValue;
}
typescript/**
* 配列が空かどうかを判定します
* null や undefined も安全に処理します
* @param items - 判定対象の配列
* @returns 空の場合は true、それ以外は false
*/
export function isEmptyArray<T>(
items: T[] | null | undefined
): boolean {
return _.isEmpty(items);
}
これらの関数は、Lodash の機能に加えて、プロジェクト固有の安全性やデフォルト動作を提供しています。
オブジェクト操作ヘルパーの実装
オブジェクト操作も、プロジェクト固有のルールを反映させることで、より安全で使いやすくなります。
ディープコピーとマージ
typescript// helpers/objects.helper.ts
import _ from 'lodash';
/**
* オブジェクトの完全なディープコピーを作成します
* @param obj - コピー対象のオブジェクト
* @returns 完全にコピーされた新しいオブジェクト
*/
export function deepClone<T>(obj: T): T {
return _.cloneDeep(obj);
}
typescript/**
* 複数のオブジェクトを安全にマージします
* 元のオブジェクトは変更されません
* @param objects - マージするオブジェクトの配列
* @returns マージされた新しいオブジェクト
*/
export function safeMerge<T extends object>(
...objects: Partial<T>[]
): T {
return _.merge({}, ...objects);
}
プロパティアクセスの安全化
typescript/**
* オブジェクトから指定されたパスの値を安全に取得します
* 途中のパスが存在しない場合はデフォルト値を返します
* @param obj - 対象オブジェクト
* @param path - プロパティパス(ドット記法)
* @param defaultValue - デフォルト値
* @returns 取得された値またはデフォルト値
*/
export function getProperty<T, R>(
obj: T,
path: string,
defaultValue: R
): R {
return _.get(obj, path, defaultValue);
}
typescript/**
* オブジェクトに指定されたパスで値を安全に設定します
* 途中のパスが存在しない場合は自動的に作成されます
* 元のオブジェクトは変更されません
* @param obj - 対象オブジェクト
* @param path - プロパティパス(ドット記法)
* @param value - 設定する値
* @returns 値が設定された新しいオブジェクト
*/
export function setPropertyImmutable<T>(
obj: T,
path: string,
value: any
): T {
const cloned = _.cloneDeep(obj);
_.set(cloned, path, value);
return cloned;
}
オブジェクトのフィルタリング
typescript/**
* オブジェクトから指定されたキーのみを抽出します
* @param obj - 対象オブジェクト
* @param keys - 抽出するキーの配列
* @returns 抽出されたプロパティを持つ新しいオブジェクト
*/
export function pickProperties<T, K extends keyof T>(
obj: T,
keys: K[]
): Pick<T, K> {
return _.pick(obj, keys) as Pick<T, K>;
}
typescript/**
* オブジェクトから指定されたキーを除外します
* @param obj - 対象オブジェクト
* @param keys - 除外するキーの配列
* @returns 指定されたキーが除外された新しいオブジェクト
*/
export function omitProperties<T, K extends keyof T>(
obj: T,
keys: K[]
): Omit<T, K> {
return _.omit(obj, keys) as Omit<T, K>;
}
これらの関数により、オブジェクト操作がより安全で、型安全になります。
ユーザー関連ヘルパーの実装
ユーザーエンティティに特化したヘルパーを実装することで、ドメインロジックを明確に表現できます。
型定義の準備
まず、ユーザーに関連する型を定義しましょう。
typescript// types/user.types.ts
/**
* ユーザーのステータス
*/
export type UserStatus =
| 'active'
| 'inactive'
| 'suspended';
/**
* ユーザーの役割
*/
export type UserRole = 'admin' | 'editor' | 'viewer';
/**
* ユーザーエンティティ
*/
export interface User {
id: string;
name: string;
email: string;
status: UserStatus;
role: UserRole;
expiredAt?: Date;
createdAt: Date;
updatedAt: Date;
}
/**
* 簡易ユーザー情報(表示用)
*/
export interface SimpleUser {
id: string;
name: string;
}
フィルタリング関数
typescript// helpers/users.helper.ts
import _ from 'lodash';
import type {
User,
UserRole,
SimpleUser,
} from '@/types/user.types';
/**
* アクティブなユーザーのみを抽出します
* @param users - ユーザーの配列
* @returns アクティブなユーザーの配列
*/
export function filterActiveUsers(users: User[]): User[] {
return _.filter(users, { status: 'active' });
}
typescript/**
* 有効期限内のユーザーを抽出します
* 有効期限が設定されていない場合は常に有効とみなします
* @param users - ユーザーの配列
* @returns 有効期限内のユーザーの配列
*/
export function filterNotExpiredUsers(
users: User[]
): User[] {
const now = new Date();
return _.filter(users, (user) => {
if (!user.expiredAt) return true;
return new Date(user.expiredAt) > now;
});
}
typescript/**
* 指定された役割を持つユーザーを抽出します
* @param users - ユーザーの配列
* @param role - 抽出する役割
* @returns 指定された役割を持つユーザーの配列
*/
export function filterUsersByRole(
users: User[],
role: UserRole
): User[] {
return _.filter(users, { role });
}
typescript/**
* 管理者ユーザーのみを抽出します
* @param users - ユーザーの配列
* @returns 管理者ユーザーの配列
*/
export function filterAdminUsers(users: User[]): User[] {
return filterUsersByRole(users, 'admin');
}
これらの関数は、ドメイン用語を使った明確な名前を持ち、型安全性も確保されています。
変換関数
typescript/**
* ユーザーを簡易フォーマットに変換します
* @param users - ユーザーの配列
* @returns 簡易フォーマットのユーザー配列
*/
export function convertToSimpleFormat(
users: User[]
): SimpleUser[] {
return _.map(users, (user) => ({
id: user.id,
name: user.name,
}));
}
typescript/**
* ユーザーをメールアドレスでグルーピングします
* @param users - ユーザーの配列
* @returns メールアドレスをキーとしたユーザーのマップ
*/
export function groupUsersByEmail(
users: User[]
): Record<string, User[]> {
return _.groupBy(users, 'email');
}
typescript/**
* ユーザーを役割でグルーピングします
* @param users - ユーザーの配列
* @returns 役割をキーとしたユーザーのマップ
*/
export function groupUsersByRole(
users: User[]
): Record<UserRole, User[]> {
return _.groupBy(users, 'role') as Record<
UserRole,
User[]
>;
}
検索関数
typescript/**
* メールアドレスでユーザーを検索します
* @param users - ユーザーの配列
* @param email - 検索するメールアドレス
* @returns 見つかったユーザー、または undefined
*/
export function findUserByEmail(
users: User[],
email: string
): User | undefined {
return _.find(users, { email });
}
typescript/**
* ID でユーザーを検索します
* @param users - ユーザーの配列
* @param id - 検索するユーザー ID
* @returns 見つかったユーザー、または undefined
*/
export function findUserById(
users: User[],
id: string
): User | undefined {
return _.find(users, { id });
}
複合処理のチェーン
複数の処理を組み合わせる場合は、各ヘルパー関数を組み合わせることで、可読性の高いコードを書けます。
typescript/**
* アクティブで有効期限内の管理者ユーザーを簡易フォーマットで取得します
* @param users - ユーザーの配列
* @returns 条件に合致するユーザーの簡易フォーマット配列
*/
export function getActiveAdminUsers(
users: User[]
): SimpleUser[] {
const activeUsers = filterActiveUsers(users);
const notExpiredUsers =
filterNotExpiredUsers(activeUsers);
const adminUsers = filterAdminUsers(notExpiredUsers);
return convertToSimpleFormat(adminUsers);
}
このように、小さな関数を組み合わせることで、複雑な処理も分かりやすく表現できますね。
下図は、ユーザー関連ヘルパーの処理フローを示しています。
mermaidflowchart LR
users["全ユーザー"] --> active["filterActiveUsers"]
active --> notExpired["filterNotExpiredUsers"]
notExpired --> admin["filterAdminUsers"]
admin --> convert["convertToSimpleFormat"]
convert --> result["簡易フォーマット<br/>ユーザー"]
各ステップで明確な責任を持つ関数を経由することで、処理の流れが追いやすくなります。
日付操作ヘルパーの実装
日付操作は、プロジェクト固有のフォーマットやタイムゾーンの扱いを統一する必要があります。
日付フォーマット
typescript// helpers/dates.helper.ts
import _ from 'lodash';
/**
* 日付を YYYY-MM-DD 形式にフォーマットします
* @param date - フォーマット対象の日付
* @returns フォーマットされた日付文字列
*/
export function formatDateAsYYYYMMDD(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(
2,
'0'
);
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
typescript/**
* 日付を YYYY/MM/DD HH:mm:ss 形式にフォーマットします
* @param date - フォーマット対象の日付
* @returns フォーマットされた日付時刻文字列
*/
export function formatDateTimeAsJapanese(
date: Date
): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(
2,
'0'
);
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(
2,
'0'
);
const seconds = String(date.getSeconds()).padStart(
2,
'0'
);
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
}
日付比較
typescript/**
* 日付が現在より過去かどうかを判定します
* @param date - 判定対象の日付
* @returns 過去の場合は true、それ以外は false
*/
export function isPastDate(date: Date): boolean {
return date < new Date();
}
typescript/**
* 日付が現在より未来かどうかを判定します
* @param date - 判定対象の日付
* @returns 未来の場合は true、それ以外は false
*/
export function isFutureDate(date: Date): boolean {
return date > new Date();
}
typescript/**
* 日付が指定された範囲内かどうかを判定します
* @param date - 判定対象の日付
* @param startDate - 範囲の開始日
* @param endDate - 範囲の終了日
* @returns 範囲内の場合は true、それ以外は false
*/
export function isDateInRange(
date: Date,
startDate: Date,
endDate: Date
): boolean {
return date >= startDate && date <= endDate;
}
これらのヘルパー関数により、日付操作がプロジェクト全体で統一され、バグのリスクが減少します。
テストコードの実装例
ヘルパー層は、独立した関数として実装されているため、テストが非常に簡単です。
配列ヘルパーのテスト
typescript// helpers/__tests__/arrays.helper.test.ts
import {
removeDuplicates,
splitIntoChunks,
safeSortBy
} from '../arrays.helper';
describe('arrays.helper', () => {
describe('removeDuplicates', () => {
it('配列から重複を除去すること', () => {
const input = [1, 2, 2, 3, 3, 3];
const expected = [1, 2, 3];
const result = removeDuplicates(input);
expect(result).toEqual(expected);
});
it('空配列の場合は空配列を返すこと', () => {
const result = removeDuplicates([]);
expect(result).toEqual([]);
});
});
typescriptdescribe('splitIntoChunks', () => {
it('配列を指定されたサイズで分割すること', () => {
const input = [1, 2, 3, 4, 5];
const expected = [[1, 2], [3, 4], [5]];
const result = splitIntoChunks(input, 2);
expect(result).toEqual(expected);
});
it('サイズが 0 以下の場合はエラーをスローすること', () => {
expect(() => splitIntoChunks([1, 2, 3], 0)).toThrow(
'Chunk size must be greater than 0'
);
});
});
typescript describe('safeSortBy', () => {
it('オブジェクト配列を昇順でソートすること', () => {
const input = [{ age: 30 }, { age: 20 }, { age: 25 }];
const expected = [{ age: 20 }, { age: 25 }, { age: 30 }];
const result = safeSortBy(input, 'age', 'asc');
expect(result).toEqual(expected);
});
it('オブジェクト配列を降順でソートすること', () => {
const input = [{ age: 30 }, { age: 20 }, { age: 25 }];
const expected = [{ age: 30 }, { age: 25 }, { age: 20 }];
const result = safeSortBy(input, 'age', 'desc');
expect(result).toEqual(expected);
});
});
});
ユーザーヘルパーのテスト
typescript// helpers/__tests__/users.helper.test.ts
import type { User } from '@/types/user.types';
import {
filterActiveUsers,
filterNotExpiredUsers,
findUserByEmail,
} from '../users.helper';
// テスト用のユーザーデータを作成する関数
function createTestUser(
overrides: Partial<User> = {}
): User {
return {
id: '1',
name: 'Test User',
email: 'test@example.com',
status: 'active',
role: 'viewer',
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
typescriptdescribe('users.helper', () => {
describe('filterActiveUsers', () => {
it('アクティブなユーザーのみを抽出すること', () => {
const users: User[] = [
createTestUser({ id: '1', status: 'active' }),
createTestUser({ id: '2', status: 'inactive' }),
createTestUser({ id: '3', status: 'active' })
];
const result = filterActiveUsers(users);
expect(result).toHaveLength(2);
expect(result[0].id).toBe('1');
expect(result[1].id).toBe('3');
});
});
typescriptdescribe('filterNotExpiredUsers', () => {
it('有効期限内のユーザーを抽出すること', () => {
const futureDate = new Date();
futureDate.setFullYear(futureDate.getFullYear() + 1);
const pastDate = new Date();
pastDate.setFullYear(pastDate.getFullYear() - 1);
const users: User[] = [
createTestUser({ id: '1', expiredAt: futureDate }),
createTestUser({ id: '2', expiredAt: pastDate }),
createTestUser({ id: '3', expiredAt: undefined }),
];
const result = filterNotExpiredUsers(users);
expect(result).toHaveLength(2);
expect(result[0].id).toBe('1');
expect(result[1].id).toBe('3');
});
});
typescript describe('findUserByEmail', () => {
it('メールアドレスでユーザーを検索できること', () => {
const users: User[] = [
createTestUser({ id: '1', email: 'user1@example.com' }),
createTestUser({ id: '2', email: 'user2@example.com' })
];
const result = findUserByEmail(users, 'user2@example.com');
expect(result).toBeDefined();
expect(result?.id).toBe('2');
});
it('見つからない場合は undefined を返すこと', () => {
const users: User[] = [
createTestUser({ id: '1', email: 'user1@example.com' })
];
const result = findUserByEmail(users, 'notfound@example.com');
expect(result).toBeUndefined();
});
});
});
このように、ヘルパー層は単体テストが書きやすく、高いテストカバレッジを維持できます。
実際のアプリケーションでの利用例
最後に、実際のアプリケーションコードでヘルパー層を使う例を見てみましょう。
Before:Lodash を直接利用
typescript// pages/api/users/active.ts(改善前)
import _ from 'lodash';
import type { NextApiRequest, NextApiResponse } from 'next';
import { prisma } from '@/lib/prisma';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
// データベースからユーザーを取得
const users = await prisma.user.findMany();
// Lodash を直接使用した処理
const activeUsers = _.filter(users, { status: 'active' });
const notExpiredUsers = _.filter(activeUsers, (user) => {
if (!user.expiredAt) return true;
return new Date(user.expiredAt) > new Date();
});
const adminUsers = _.filter(notExpiredUsers, {
role: 'admin',
});
const result = _.map(adminUsers, (user) => ({
id: user.id,
name: user.name,
}));
res.status(200).json(result);
}
このコードは動作しますが、処理の意図が分かりにくく、同じようなコードが他の場所でも重複しがちです。
After:ヘルパー層を利用
typescript// pages/api/users/active.ts(改善後)
import type { NextApiRequest, NextApiResponse } from 'next';
import { prisma } from '@/lib/prisma';
import { getActiveAdminUsers } from '@/helpers';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
// データベースからユーザーを取得
const users = await prisma.user.findMany();
// ヘルパー関数を使用した処理
const result = getActiveAdminUsers(users);
res.status(200).json(result);
}
改善後のコードは、処理の意図が明確で、コードの行数も大幅に削減されています。さらに、getActiveAdminUsers
関数はテスト済みであるため、バグのリスクも低いでしょう。
下図は、改善前後のコード構造を比較したものです。
mermaidflowchart TD
subgraph before["改善前:直接利用"]
api1["API ハンドラ"] --> lodash1["Lodash 直接呼び出し"]
lodash1 --> logic1["複雑なビジネスロジック<br/>散在・重複"]
end
subgraph after["改善後:ヘルパー層"]
api2["API ハンドラ"] --> helper["ヘルパー関数"]
helper --> lodash2["Lodash"]
helper --> logic2["ビジネスロジック<br/>集約・再利用"]
end
このように、ヘルパー層を導入することで、コードの品質が大きく向上します。
まとめ
Lodash を"薄いヘルパー層"として包むことで、以下のメリットが得られます。
得られるメリット
# | メリット | 詳細 |
---|---|---|
1 | 可読性の向上 | ドメイン用語を使った関数名により、コードの意図が明確になる |
2 | 保守性の向上 | 実装の変更が 1 箇所で済み、影響範囲が限定される |
3 | テスト容易性 | 独立した関数として実装されているため、単体テストが簡単 |
4 | 型安全性の向上 | プロジェクト固有の型定義により、コンパイル時にエラーを検出 |
5 | 再利用性の向上 | 共通処理を 1 箇所に集約し、コードの重複を削減 |
設計のポイント
薄いヘルパー層を設計する際は、以下のポイントを意識しましょう。
単一責任の原則を守る
各ヘルパー関数は、1 つの明確な責任だけを持つべきです。複数の処理を 1 つの関数にまとめると、再利用性が低下します。
ドメイン用語を使う
関数名には、プロジェクト固有のドメイン用語を反映させましょう。これにより、コードの可読性が大幅に向上します。
型安全性を最大限に活用する
TypeScript の型システムを活用し、プロジェクト固有の型を定義しましょう。型による保護により、バグを早期に発見できます。
内部実装を隠蔽する
ヘルパー層の内部実装(Lodash を使うか、独自実装を使うか)は、外部から見えないようにすべきです。これにより、将来的な実装変更が容易になります。
今すぐ始められるステップ
Lodash を薄いヘルパー層で包む取り組みは、以下のステップで始められます。
ステップ 1:ヘルパー用のディレクトリを作成する
typescript// プロジェクトに helpers ディレクトリを作成
src/helpers/
ステップ 2:よく使う処理から関数化を始める
プロジェクト内で頻繁に使われている Lodash の処理を特定し、ヘルパー関数として切り出しましょう。
ステップ 3:型定義を整備する
プロジェクト固有の型を types/
ディレクトリに定義し、ヘルパー関数で使用しましょう。
ステップ 4:テストを書く
ヘルパー関数のテストを書き、動作を保証しましょう。テストがあることで、リファクタリングも安心して行えます。
ステップ 5:既存コードを段階的に移行する
既存のコードを一度にすべて書き換えるのではなく、新規実装や修正のタイミングで徐々にヘルパー層を利用するように移行しましょう。
この記事で紹介した設計指針と実装例を参考に、ぜひプロジェクトに薄いヘルパー層を導入してみてください。長期的なメンテナンス性の向上に、きっと役立つはずです。
関連リンク
- article
Lodash を“薄いヘルパー層”として包む:プロジェクト固有ユーティリティの設計指針
- article
Lodash で巨大 JSON を“正規化 → 集計 → 整形”する 7 ステップ実装
- article
Lodash クイックレシピ :配列・オブジェクト変換の“定番ひな形”集
- article
Lodash を部分インポートで導入する最短ルート:ESM/TS/バンドラ別の設定集
- article
Lodash の全体像を 1 枚絵で把握する:配列・オブジェクト・関数操作の設計マップ
- article
Lodash の throttle・debounce でパフォーマンス最適化
- article
Docker マルチステージビルド設計大全:テスト分離・依存最小化・キャッシュ戦略
- article
Devin で既存バグを最短修正:再現 → 原因特定 → 最小修正 → 回帰テストの一連プロンプト
- article
Lodash を“薄いヘルパー層”として包む:プロジェクト固有ユーティリティの設計指針
- article
Convex で実践する CQRS/イベントソーシング:履歴・再生・集約の設計ガイド
- article
LangChain と LlamaIndex の設計比較:API 哲学・RAG 構成・運用コストを検証
- article
Jotai が再レンダリング地獄に?依存グラフの暴走を止める診断手順
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来