🙆‍♀️

Array.reduce()を使った重複排除と最新レコード抽出のテクニック

に公開

はじめに

データ処理でよくある課題として、「同じIDを持つレコードが複数存在する場合、最新のもののみを取得したい」というケースがあります。今回は、Array.reduce()を使った効率的な解決方法を紹介します。

実際のケース:リトライ処理での最新レコード抽出

問題設定

以下のような「商品の処理履歴データ」があるとします:

interface ProcessedItem {
  productId: string;
  retrySequence: number;  // 0=初回, 1=1回目再処理, 2=2回目再処理...
  status: 'COMPLETED' | 'FAILED';
  processedAt: Date;
}

const items: ProcessedItem[] = [
  { productId: "PROD001", retrySequence: 0, status: "FAILED", processedAt: new Date('2024-01-01') },
  { productId: "PROD002", retrySequence: 0, status: "COMPLETED", processedAt: new Date('2024-01-01') },
  { productId: "PROD001", retrySequence: 1, status: "FAILED", processedAt: new Date('2024-01-02') },
  { productId: "PROD001", retrySequence: 2, status: "COMPLETED", processedAt: new Date('2024-01-03') },
  { productId: "PROD002", retrySequence: 1, status: "COMPLETED", processedAt: new Date('2024-01-02') }
];

欲しい結果:各productIdについて、最新のretrySequenceを持つレコードのみを取得

解決方法:reduceを使った重複排除

const latestItems = items.reduce(
  (acc, item) => {
    const key = item.productId;
    const existing = acc[key];

    // 既存レコードがないか、現在のアイテムの方が新しい場合は更新
    if (!existing || item.retrySequence > existing.retrySequence) {
      acc[key] = item;
    }

    return acc;
  },
  {} as Record<string, ProcessedItem>
);

// オブジェクトから配列に変換
const latestItemsArray = Object.values(latestItems);

コードの詳細解説

1. 初期値の設定

{} as Record<string, ProcessedItem>
  • 空のオブジェクトを初期値として設定
  • Record<string, ProcessedItem>は「文字列をキーとし、ProcessedItemを値とするオブジェクト」の型定義

2. キーと既存レコードの取得

const key = item.productId;
const existing = acc[key];
  • key:グループ化の基準となるID
  • existing:既にアキュムレータに保存されている同じIDのレコード

3. 最新判定ロジック

if (!existing || item.retrySequence > existing.retrySequence) {
  acc[key] = item;
}
  • 条件1: !existing → 初回の場合(そのIDのレコードが初めて処理される)
  • 条件2: item.retrySequence > existing.retrySequence → 現在のレコードの方が新しい場合

実行結果

処理過程の可視化

処理順 ID retrySequence 判定 アキュムレータの状態
1 "PROD001" 0 初回 {"PROD001": item1}
2 "PROD002" 0 初回 {"PROD001": item1, "PROD002": item2}
3 "PROD001" 1 1 > 0 {"PROD001": item3, "PROD002": item2}
4 "PROD001" 2 2 > 1 {"PROD001": item4, "PROD002": item2}
5 "PROD002" 1 1 > 0 {"PROD001": item4, "PROD002": item5}

最終結果

// latestItems の内容
{
  "PROD001": { productId: "PROD001", retrySequence: 2, status: "COMPLETED" },
  "PROD002": { productId: "PROD002", retrySequence: 1, status: "COMPLETED" }
}

他の解決方法との比較

Map + forEach を使った場合

const latestMap = new Map<string, ProcessedItem>();
items.forEach(item => {
  const existing = latestMap.get(item.productId);
  if (!existing || item.retrySequence > existing.retrySequence) {
    latestMap.set(item.productId, item);
  }
});
const latestItems = Array.from(latestMap.values());

filter + sort を使った場合(非効率)

const uniqueIds = [...new Set(items.map(item => item.productId))];
const latestItems = uniqueIds.map(id =>
  items
    .filter(item => item.productId === id)
    .sort((a, b) => b.retrySequence - a.retrySequence)[0]
);

比較結果:

  • reduce: 一度のループで処理完了、メモリ効率良好
  • Map + forEach: 読みやすいが、reduceと性能はほぼ同等
  • filter + sort: 読みやすいが、O(n²)で非効率

応用パターン

1. 日付での最新レコード抽出

const latestByDate = items.reduce(
  (acc, item) => {
    const key = item.productId;
    const existing = acc[key];

    if (!existing || item.processedAt > existing.processedAt) {
      acc[key] = item;
    }

    return acc;
  },
  {} as Record<string, ProcessedItem>
);

2. 複数条件での最新レコード抽出

const latestByMultipleCriteria = items.reduce(
  (acc, item) => {
    const key = item.productId;
    const existing = acc[key];

    if (!existing ||
        item.retrySequence > existing.retrySequence ||
        (item.retrySequence === existing.retrySequence &&
          item.processedAt > existing.processedAt)) {
      acc[key] = item;
    }

    return acc;
  },
  {} as Record<string, ProcessedItem>
);

3. より実用的な例:注文履歴の最新ステータス取得

interface OrderStatusLog {
  orderId: string;
  status: 'PENDING' | 'PROCESSING' | 'SHIPPED' | 'DELIVERED';
  updatedAt: Date;
  version: number;
}

const orderLogs: OrderStatusLog[] = [
  { orderId: "ORD001", status: "PENDING", updatedAt: new Date('2024-01-01'), version: 1 },
  { orderId: "ORD002", status: "PENDING", updatedAt: new Date('2024-01-01'), version: 1 },
  { orderId: "ORD001", status: "PROCESSING", updatedAt: new Date('2024-01-02'), version: 2 },
  { orderId: "ORD001", status: "SHIPPED", updatedAt: new Date('2024-01-03'), version: 3 },
  { orderId: "ORD002", status: "DELIVERED", updatedAt: new Date('2024-01-02'), version: 2 }
];

const latestOrderStatus = orderLogs.reduce(
  (acc, log) => {
    const key = log.orderId;
    const existing = acc[key];

    if (!existing || log.version > existing.version) {
      acc[key] = log;
    }

    return acc;
  },
  {} as Record<string, OrderStatusLog>
);

4. 型安全性の向上

// より型安全なバージョン
const getLatestItems = <T extends { [K in keyof T]: any }>(
  items: T[],
  keySelector: (item: T) => string,
  compareSelector: (item: T) => number
): T[] => {
  const latest = items.reduce(
    (acc, item) => {
      const key = keySelector(item);
      const existing = acc[key];

      if (!existing || compareSelector(item) > compareSelector(existing)) {
        acc[key] = item;
      }

      return acc;
    },
    {} as Record<string, T>
  );

  return Object.values(latest);
};

// 使用例
const result = getLatestItems(
  items,
  item => item.productId,
  item => item.retrySequence
);

まとめ

Array.reduce()を使った重複排除と最新レコード抽出は、以下の特徴があります:

メリット:

  • 一度のループで処理完了(O(n))
  • メモリ効率が良い
  • 関数型プログラミングのパラダイムに適合

注意点:

  • 初見では理解しにくい場合がある
  • デバッグがやや困難

使用場面:

  • データベースから取得したレコードの重複排除
  • APIレスポンスの最新データ抽出
  • ログデータの集計処理
  • 注文履歴やステータス更新の最新状態取得

この手法は実際のWebアプリケーション開発でよく使われるパターンなので、ぜひ参考にしてください。

Discussion