TypeScript mapの実務的な使い方|データ変換パターンと実装例

未分類

TypeScript mapの実務的な使い方|データ変換パターンと実装例

JavaScriptおよびTypeScriptを使った開発では、配列のデータ変換が日常的な業務です。その中でもmapメソッドは最頻出の高階関数の一つです。本記事では、教科書的な説明ではなく、実際のプロジェクトで使用されている実務パターンを中心に解説します。

1. map関数の基本概念

mapは配列の各要素に対して処理を適用し、新しい配列を返すメソッドです。元の配列を変更せず(イミュータブル)、変換後の新しい配列を生成することが特徴です。

基本的なシグネチャは以下の通りです:

array.map<U>(callbackfn: (value: T, index: number, array: T[]) => U, thisArg?: any): U[]

TypeScriptでは、入力型Tと出力型Uを明示的に指定できるため、型安全性が保証されます。

2. 業務でのユースケース分析

2-1. APIレスポンスのデータ正規化

最も一般的なユースケースがAPIからのレスポンス処理です。バックエンドから受け取るデータ構造が、フロントエンドで必要な形式と異なることは日常茶飯事です。

例えば、ECサイトのカート機能では、商品マスタAPIから商品IDと価格を取得し、ユーザー情報と組み合わせて表示する必要があります:

// APIレスポンスの型定義
interface ProductApiResponse {
  id: string;
  name: string;
  basePrice: number;
  taxRate: number;
  stock: number;
  createdAt: string;
}

// フロントエンドで必要な型
interface CartItem {
  productId: string;
  displayName: string;
  priceWithTax: number;
  isAvailable: boolean;
}

// 実装例:APIレスポンスを正規化
const normalizeProducts = (products: ProductApiResponse[]): CartItem[] => {
  return products.map((product) => ({
    productId: product.id,
    displayName: product.name.toUpperCase(),
    priceWithTax: Math.round(product.basePrice * (1 + product.taxRate / 100)),
    isAvailable: product.stock > 0,
  }));
};

// 使用例
const apiResponse: ProductApiResponse[] = [
  {
    id: 'prod_001',
    name: 'ノートPC',
    basePrice: 100000,
    taxRate: 10,
    stock: 5,
    createdAt: '2024-01-15',
  },
  {
    id: 'prod_002',
    name: 'マウス',
    basePrice: 3000,
    taxRate: 10,
    stock: 0,
    createdAt: '2024-01-16',
  },
];

const cartItems = normalizeProducts(apiResponse);
console.log(cartItems);
// 出力:
// [
//   { productId: 'prod_001', displayName: 'ノートPC', priceWithTax: 110000, isAvailable: true },
//   { productId: 'prod_002', displayName: 'マウス', priceWithTax: 3300, isAvailable: false }
// ]

2-2. ネストされたデータの展開

複数の関連テーブルから取得したデータを統合する場合、mapを使用してネストされた構造を扱います。

// ユーザーと注文履歴の統合データ
interface OrderDetail {
  orderId: string;
  amount: number;
  status: 'pending' | 'completed' | 'cancelled';
}

interface UserWithOrders {
  userId: string;
  userName: string;
  email: string;
  orders: OrderDetail[];
}

interface FlattenedUserOrder {
  userId: string;
  userName: string;
  orderId: string;
  amount: number;
  status: string;
}

// ネストされたデータを平坦化
const flattenUserOrders = (users: UserWithOrders[]): FlattenedUserOrder[] => {
  return users.flatMap((user) =>
    user.orders.map((order) => ({
      userId: user.userId,
      userName: user.userName,
      orderId: order.orderId,
      amount: order.amount,
      status: order.status,
    }))
  );
};

const usersData: UserWithOrders[] = [
  {
    userId: 'user_001',
    userName: '田中太郎',
    email: 'tanaka@example.com',
    orders: [
      { orderId: 'order_001', amount: 50000, status: 'completed' },
      { orderId: 'order_002', amount: 12000, status: 'pending' },
    ],
  },
  {
    userId: 'user_002',
    userName: '鈴木花子',
    email: 'suzuki@example.com',
    orders: [
      { orderId: 'order_003', amount: 75000, status: 'completed' },
    ],
  },
];

const flatOrders = flattenUserOrders(usersData);
console.log(flatOrders.length); // 3

3. 実務での複合的な実装パターン

3-1. 条件付き変換とエラーハンドリング

実務では、すべての要素が正常に変換できるとは限りません。エラーハンドリングを含めたロバストな実装が必要です:

interface RawSensorData {
  sensorId: string;
  rawValue: string; // 数値として不正な値も含まれる可能性
  timestamp: number;
}

interface ProcessedSensorData {
  sensorId: string;
  value: number;
  isValid: boolean;
  errorMessage?: string;
  timestamp: Date;
}

// エラーハンドリング付きのmapパターン
const processSensorData = (
  rawData: RawSensorData[]
): ProcessedSensorData[] => {
  return rawData.map((data) => {
    try {
      const parsedValue = parseFloat(data.rawValue);
      
      // バリデーション:センサー値が正常範囲か確認
      if (isNaN(parsedValue)) {
        return {
          sensorId: data.sensorId,
          value: 0,
          isValid: false,
          errorMessage: '数値に変換できません',
          timestamp: new Date(data.timestamp),
        };
      }

      if (parsedValue < -40 || parsedValue > 80) {
        return {
          sensorId: data.sensorId,
          value: parsedValue,
          isValid: false,
          errorMessage: '正常範囲外の値です',
          timestamp: new Date(data.timestamp),
        };
      }

      return {
        sensorId: data.sensorId,
        value: parsedValue,
        isValid: true,
        timestamp: new Date(data.timestamp),
      };
    } catch (error) {
      return {
        sensorId: data.sensorId,
        value: 0,
        isValid: false,
        errorMessage: '予期しないエラーが発生しました',
        timestamp: new Date(data.timestamp),
      };
    }
  });
};

const sensorInput: RawSensorData[] = [
  { sensorId: 'sensor_001', rawValue: '25.5', timestamp: 1704067200000 },
  { sensorId: 'sensor_002', rawValue: 'invalid', timestamp: 1704067260000 },
  { sensorId: 'sensor_003', rawValue: '100', timestamp: 1704067320000 },
];

const processedData = processSensorData(sensorInput);
console.log(processedData);

3-2. パフォーマンスを考慮した大規模データ処理

大量データ処理時は、mapの効率を意識する必要があります。オブジェクトの再作成やメモリ使用量に注意が必要です:

interface LogEntry {
  level: 'INFO' | 'WARN' | 'ERROR';
  message: string;
  timestamp: number;
  userId?: string;
  metadata?: Record;
}

interface AggregatedLog {
  level: string;
  count: number;
  firstOccurrence: Date;
  lastOccurrence: Date;
}

// ログエントリを集約(大規模データ用)
const aggregateLogs = (logs: LogEntry[]): Map => {
  const aggregated = new Map();

  logs.forEach((log) => {
    const key = log.level;
    const date = new Date(log.timestamp);

    if (aggregated.has(key)) {
      const existing = aggregated.get(key)!;
      existing.count++;
      if (date > existing.lastOccurrence) {
        existing.lastOccurrence = date;
      }
    } else {
      aggregated.set(key, {
        level: log.level,
        count: 1,
        firstOccurrence: date,
        lastOccurrence: date,
      });
    }
  });

  return aggregated;
};

// 大規模データのシミュレーション
const generateLogs = (count: number): LogEntry[] => {
  const levels: ('INFO' | 'WARN' | 'ERROR')[] = ['INFO', 'WARN', 'ERROR'];
  const logs: LogEntry[] = [];

  for (let i = 0; i < count; i++) {
    logs.push({
      level: levels[i % 3],
      message: `Log message ${i}`,
      timestamp: Date.now() - Math.random() * 86400000,
    });
  }

  return logs;
};

const logs = generateLogs(10000);
const aggregated = aggregateLogs(logs);
console.log(Array.from(aggregated.values()));

4. よくある応用パターン

4-1. mapとfilterの組み合わせ

データ変換と同時にフィルタリングが必要な場合、filtermapを組み合わせます:

interface Product {
  id: string;
  name: string;
  price: number;
  category: string;
  inStock: boolean;
}

interface DiscountedProduct {
  id: string;
  name: string;
  originalPrice: number;
  discountedPrice: number;
  savingAmount: number;
}

// 在庫ありで、かつ価格が高い商品のみ割引適用
const getDiscountedProducts = (
  products: Product[],
  discountRate: number = 0.1
): DiscountedProduct[] => {
  return products
    .filter((product) => product.inStock && product.price > 10000)
    .map((product) => ({
      id: product.id,
      name: product.name,
      originalPrice: product.price,
      discountedPrice: Math.floor(product.price * (1 - discountRate)),
      savingAmount: Math.floor(product.price * discountRate),
    }));
};

const products: Product[] = [
  {
    id: 'p001',
    name: 'プリンター',
    price: 25000,
    category: 'electronics',
    inStock: true,
  },
  {
    id: 'p002',
    name: 'ペン',
    price: 500,
    category: 'stationery',
    inStock: true,
  },
  {
    id: 'p003',
    name: 'デスク',
    price: 15000,
    category: 'furniture',
    inStock: false,
  },
];

const discounted = getDiscountedProducts(products, 0.15);
console.log(discounted);

4-2. オブジェクトの配列をキーバリューMapへの変換

interface UserProfile {
  id: string;
  name: string;
  email: string;
  role: string;
}

// 配列をMapに変換(高速ルックアップ用)
const createUserMap = (users: UserProfile[]): Map => {
  return new Map(users.map((user) => [user.id, user]));
};

const users: UserProfile[] = [
  { id: 'u001', name: '山田太郎', email: 'yamada@example.com', role: 'admin' },
  { id: 'u002', name: '田中花子', email: 'tanaka@example.com', role: 'user' },
  { id: 'u003', name: '鈴木次郎', email: 'suzuki@example.com', role: 'user' },
];

const userMap = createUserMap(users);
console.log(userMap.get('u001')?.name); // 山田太郎

4-3. mapを使った依存関係の解決

interface DependencyConfig {
  name: string;
  version: string;
  dependencies?: string[];
}

interface ResolvedDependency {
  name: string;
  version: string;
  resolvedDependencies: DependencyConfig[];
}

// 依存関係を解決するヘルパー関数
const resolveDependencies = (
  configs: DependencyConfig[],
  configMap: Map
): ResolvedDependency[] => {
  return configs.map((config) => ({
    name: config.name,
    version: config.version,
    resolvedDependencies: (config.dependencies || []).map(
      (depName) => configMap.get(depName)!
    ),
  }));
};

const depConfigs: DependencyConfig[] = [
  {
    name: 'express',
    version: '4.18.0',
    dependencies: ['body-parser'],
  },
  {
    name: 'body-parser',
    version: '1.20.0',
    dependencies: [],
  },
  {
    name: 'lodash',
    version: '4.17.21',
    dependencies: [],
  },
];

const configMap = new Map(depConfigs.map((c) => [c.name, c]));
const resolved = resolveDependencies(depConfigs, configMap);
console.log(resolved);

5. TypeScript特有の注意点と最適化

5-1. 型推論とジェネリクス

TypeScriptは通常、mapの戻り値の型を推論できますが、複雑な変換では明示的に型を指定することが推奨されます:

// 明示的な型指定で型安全性を確保
const transformData = <T, U>(
  items: T[],
  transformer: (item: T) => U
): U[] => {
  return items.map(transformer);
};

interface Source {
  id: number;
  value: string;
}

interface Target {
  identifier: number;
  content: string;
}

const sources: Source[] = [
  { id: 1, value: 'hello' },
  { id: 2, value: 'world' },
];

// 型安全な変換
const targets = transformData<Source, Target>(sources, (source) => ({
  identifier: source.id,
  content: source.value.toUpperCase(),
}));

console.log(targets); // Type: Target[]

5-2. Partial型とオプショナルプロパティの処理

interface FullUser {
  id: string;
  name: string;
  email: string;
  phone: string;
  address: string;
}

type PartialUser = Partial;

// 部分的なデータを完全な形に変換
const fillMissingFields = (
  partialUsers: PartialUser[],
  defaults: FullUser
): FullUser[] => {
  return partialUsers.map((user) => ({
    id: user.id ?? defaults.id,
    name: user.name ?? defaults.name,
    email: user.email ?? defaults.email,
    phone: user.phone ?? defaults.phone,
    address: user.address ?? defaults.address,
  }));
};

const incomplete: PartialUser[] = [
  { id: 'u001', name: '太郎' },
  { id: 'u002', email: 'email@example.com' },
];

const defaults: FullUser = {
  id: 'default',
  name: '名義人',
  email: 'default@example.com',
  phone: '000-0000-0000',
  address: '東京都',
};

const complete = fillMissingFields(incomplete, defaults);

5-3. 非同期操作との組み合わせ

実務では、非同期処理を伴うデータ変換が頻繁に発生します。この場合、Promise配列を扱う必要があります:

interface RemoteResource {
  id: string;
  url: string;
}

interface CachedResource {
  id: string;
  content: string;
  fetchedAt: Date;
}

// 非同期変換:複数リソースを並列取得
const fetchAndCacheResources = async (
  resources: RemoteResource[]
): Promise => {
  // 全リクエストを並列実行
  const promises = resources.map(async (resource) => {
    try {
      // 実際の実装ではfetchやaxiosを使用
      const response = await fetch(resource.url);
      const content = await response.text();
      
      return {
        id: resource.id,
        content: content,
        fetchedAt: new Date(),
      };
    } catch (error) {
      console.error(`Failed to fetch ${resource.id}:`, error);
      return {
        id: resource.id,
        content: '',
        fetchedAt: new Date(),
      };
    }
  });

  return Promise.all(promises);
};

// 使用例
const resources: RemoteResource[] = [
  { id: 'r001', url: 'https://example.com/data1' },
  { id: 'r002', url: 'https://example.com/data2' },
];

// await fetchAndCacheResources(resources);

6. パフォーマンスに関する注意点

6-1. 大規模配列でのメモリ効率

非常に大規模なデータセット(数百万件以上)では、mapが全体をメモリに展開するため、ジェネレータやストリーム処理の検討が必要です:

// ジェネレータを使った遅延評価
function* mapLazy<T, U>(
  items: T[],
  transformer: (item: T) => U
): Generator<U> {
  for (const item of items) {
    yield transformer(item);
  }
}

// 使用例
const numbers = Array.from({ length: 1000000 }, (_, i) => i);
const doubled = mapLazy(numbers, (n) => n * 2);

// 必要な分だけ処理
let count = 0;
for (const value of doubled) {
  if (count++ >= 10) break;
  console.log(value);
}

6-2. 過度なネストの回避

mapを複数回ネストすると、処理が複雑になりパフォーマンスも低下します:

// 非効率な例:複数回のmap
const inefficient = data
  .map((x) => x.value)
  .map((y) => y * 2)
  .map((z) => z + 10);

// 効率的な例:単一のmapで統合
const efficient = data.map((x) => x.value * 2 + 10);

7. デバッグとテスト

実務では、mapを使った変換結果をテストすることは重要です:

// テスト例(Jest)
describe('データ変換テスト', () => {
  test('normalizeProducts は正しく価格を計算する', () => {
    const input: ProductApiResponse[] = [
      {
        id: 'prod_001',
        name: 'テスト商品',
        basePrice: 1000,
        taxRate: 10,
        stock: 5,
        createdAt: '2024-01-01',
      },
    ];

    const result = normalizeProducts(input);

    expect(result).toHaveLength(1);
    expect(result[0].priceWithTax).toBe(1100);
    expect(result[0].isAvailable).toBe(true);
  });

  test('在庫なし商品は isAvailable が false', () => {
    const input: ProductApiResponse[] = [
      {
        id: 'prod_002',
        name: '在庫なし',
        basePrice: 5000,
        taxRate: 10,
        stock: 0,
        createdAt: '2024-01-01',
      },
    ];

    const result = normalizeProducts(input);
    expect(result[0].isAvailable).toBe(false);
  });
});

まとめ

TypeScriptのmapメソッドは、単なる配列変換ツールではなく、実務におけるデータ処理パイプラインの中核をなします。重要なポイントは以下の通りです:

  • 型安全性: TypeScriptの型推論を活用し、変換前後のデータ構造を明確に定義する
  • イミュータビリティ: 元データを変更せず、新しい配列を生成する原則を守る
  • エラーハンドリング: 本番環境では、予期しないデータにも対応する実装が必須
  • パフォーマンス意識: 大規模データ処理時は、メモリ効率とアルゴリズムの効率を検討する
  • 組み合わせの工夫: filterreduceなどと組み合わせ、複雑な処理を簡潔に記述する

これらのパターンを習得することで、保守性が高く、スケーラブルなTypeScriptコードを書くことができます。

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