📘

【TS/JS】ユースケース別 ループの書き方【同期/非同期/直列/並列】

に公開
2

はじめに

JavaScript, TypeScriptにはループを実現する方法がいくつもあり、ユースケースによって最適なものが異なります。
今では理解できるようになりましたが、初心者の頃はループの書き方が多すぎてどれを使っていいのかわからず、適切でない使用方法をしていたことがありました。
forループの種類を誰かに説明するにはあまりにパターンが多く、まとまっている記事も見当たらなかったため、この記事でまとめます。
ぜひチートシートとしてご活用ください!

配列の要素を繰り返し処理する

const items = [1, 2, 3, 4, 5];
for (const item of items) {
  console.log(item);
}

TypeScript Playground

まずは基本型です。
for ... inという似たような構文もあり混乱してしまいますが、for ... inは継承元のプロパティも含めて列挙する仕様となっており、デバッグ用途以外で使うことはほとんどありません。
多くのスタイルガイドで非推奨とされていることが多いので、for ... ofを使いましょう。

indexを取りたい場合

const items = [1, 2, 3, 4, 5];
for (const [index, item] of items.entries()) {
  console.log(index, item);
}

TypeScript Playground

items.entries()で配列のインデックスと値のペアを取得できます。

別の書き方

const items = [1, 2, 3, 4, 5];
items.forEach((item, index) => {
  console.log(index, item);
});

TypeScript Playground

forEachを使うことでより簡潔に書くことができます。
ただし、こちらはbreakcontinueができず非同期処理が意図した通りに動作しないなど癖があります。

式として値を返したい場合

const items = [1, 2, 3, 4, 5];
const result = items.map((item, index) => {
  return `${index}: ${item}`;
});
console.log(result);

TypeScript Playground

forEachは戻り値がvoidとなっており、値を返せない仕様になっています。
値を返したい場合にはそのための関数としてmapが用意されています。
逆に、値を返さないのにmapを使うのはコードの意図が伝わりづらくなってしまうため、やめましょう。
配列には他にも多くの便利なメソッドがあり面白いので、興味を持った方はぜひ調べてみてくださいね。

指定回数繰り返す

他の言語ではfor n in range(10)のような配列を生成する組み込み関数が用意されていることもありますが、JavaScriptにそれはないため、自力で用意する必要があります。
そのため書き方が複数あり、好みになってしまいます。

const count = 10;
for (let i = 0; i < count; i++) {
  console.log(i);
}

TypeScript Playground

一番良く見るパターンです。
iletで宣言されているため、ループ内で値を変更できてしまうデメリットがあります。
実害はあまりないもしれませんが、コーディング規約によっては禁止されていることもあるので、使わずに済む方法も覚えておくとよいでしょう。

const count = 100;
for (const i of Array(count).keys()) {
  console.log(i);
}

TypeScript Playground

こちらは別解です。
Array(count)で指定した長さの空の配列を生成し、keys()でそのindexだけを取り出しています。
Array.from({ length: count }, (_, i) => i)などよりもサクッと作れるのでおすすめです。

条件を満たす間繰り返す

whileを使った方法

class SmartPhone {
  private battery: number = 0;

  charge(): void {
    this.battery += 10;
    console.log('charge:', this.battery);
  }

  isLowBattery(): boolean {
    console.log('isLowBattery:', this.battery < 100);
    return this.battery < 100;
  }
}

const smartPhone = new SmartPhone();

while(smartPhone.isLowBattery()) { // trueの間繰り返す
  smartPhone.charge();
}

TypeScript Playground

whileを使うことで条件を満たす間繰り返す処理を実現できます。
途中でcontinuebreakを使うことで、ループを抜けたりスキップしたりすることもできます。

do whileを使った方法

const smartPhone = new SmartPhone();

do {
  smartPhone.charge();
} while(smartPhone.isLowBattery());

do whileを使うことで最初の一回だけ条件を無視して処理を実行することもできます。

前回の結果を次のループに使いたい

const hanoi = (height: number, first: number, temp: number, last: number): void => {
  if (height === 0) return; // 早期リターンで止め時を指定しておく
  hanoi(height - 1, first, last, temp);
  console.log(`${first} -> ${last}`);
  hanoi(height - 1, temp, first, last);
};

hanoi(3, 1, 2, 3);

TypeScript Playground

自分自身を呼び出すことで次のループに前回の結果を引き継ぐことができます。
この方法は末尾再帰の実装があるSafariやBun以外のランタイムではStack Overflowを起こす可能性があるため、注意が必要です。

再帰的な構造が必要なければreduceを使うことで同様の処理を実現できます。

const items = [1, 2, 3, 4, 5];

const result = items.reduce((acc, item) => {
  console.log(acc, item);
  return acc + item;
}, 0);

console.log('Total:', result);

非同期処理

非同期処理を含む場合、ループはさらに複雑になります。

直列処理

const items = [1, 2, 3, 4, 5];

const process = async (item: number) => new Promise((resolve) => {
    console.log(item);
    setTimeout(resolve, 1000);
});

const run = async () => {
  for (const item of items) {
    await process(item);
  }
};

void run();

TypeScript Playground

直列処理は、配列の要素を順番に一つずつ待ち、終わり次第次の要素を処理するものです。
このような処理がしたい場合はforEachでは実現できません。
for ... ofのブロック内ではawaitを使うことができるため、非同期関数を呼び出すことができます。

直列処理かつ結果を取得したい場合

const items = [1, 2, 3, 4, 5];

const process = async (item: number): Promise<number> => 
  new Promise((resolve) => {
      console.log(item);
      setTimeout(() => resolve(item), 1000);
  });

const run = async () => {
  const result = await Array.fromAsync(items, process);
  console.log(result);
}

void run();

TypeScript Playground

ES2024で追加された新しい書き方です。
Promise.allArray.fromの間の子のような関数で、こちらも配列の要素を順番に待機して処理することができます。
以前はこの書き方ができず、結果を可変の配列に追加する必要がありました。
新しい書き方のため、環境によってはpolyfillを用意しないといけない点に注意してください。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/fromAsync

並列処理

const items = [1, 2, 3, 4, 5];

const process = async (item: number): Promise<number> => 
  new Promise((resolve) => {
      console.log(item);
      setTimeout(() => resolve(item), 1000);
  });

const run = async () => {
  const result = await Promise.all(items.map(item => process(item)));
  console.log(result);
}

void run();

TypeScript Playground

並列処理は、実行順序を気にせずに全ての要素を一度に処理する場合に使います。
Promise.allPromiseの配列を受取り、全てのPromiseが完了するまで待機する関数となっています。
引数はArrayLike<Promise<T>>を取るため、あらかじめ実行するPromiseの配列を作成して渡してあげる必要があります。
awaitしないで呼び出した非同期関数はPromiseを返すため、mapのコールバックに渡してあげることでPromiseの配列を作成できます。

並列処理かつ、失敗しても処理を続行したい場合

const items = [1, 2, 3, 4, 5];

const process = async (item: number) => {
  await new Promise(resolve => setTimeout(resolve, 1000));
  if (item === 3) {
    throw new Error("error");
  }
  return item;
};

const run = async () => {
  const result = await Promise.allSettled(items.map(item => process(item)));
  result.forEach((promise, index) => {
    if (promise.status === "fulfilled") {
      console.log(index, promise.value);
    } else {
      console.error(index, promise.reason);
    }
  });
}

void run();

TypeScript Playground
Promise.allSettledPromise.allと似ていますが、失敗してもErrorをthrowすることなく処理を続行します。
戻り値は{ status: "fulfilled", value: T } | { status: "rejected", reason: any }の配列で、Result型になっています。
戻り値のstatusプロパティで成功か失敗かを判定でき、それぞれvalueで値、reasonでエラーを取得できます。

まとめ

JavaScript, TypeScriptにはループを実現する方法がいくつもあり、ユースケースによって最適なものが異なります。
意外にも最近追加されたばかりの関数もあり、知らなかったという方もいるのではないでしょうか。
ぜひこの記事を参考にして、最適なループの書き方を見つけてください!

Discussion

michiharumichiharu

「indexを取りたい場合」が勉強になりました。Object や Map を使うときに .entries() をよく使いますが、配列に使う発想はありませんでした。i < items.length といった終了条件の古風な?for文の書き方で頑張っていましたが、今後は .entries() も覚えて使い分けます!

じょうげんじょうげん

励みになるコメントありがとうございます。.entries()と.keys()はこの記事で一番知ってもらいたかったところだったので、知ってもらえて良かったです!
私もObjectのイメージが強く、Arrayにも生えていて同じように使えるというのを知ったのは最近でした。