はじめに
JavaScriptやTypeScriptで配列を変換する際、for文を使って以下のように書いていないだろうか?
const numbers: number[] = [1, 2, 3, 4, 5];
const doubled: number[] = [];
for (let i = 0; i < numbers.length; i++) {
doubled.push(numbers[i] * 2);
} この記事では、for文による配列変換の問題点とmap()を使うメリットについて解説する。
※この記事ではサンプルコードとしてTypeScriptを使っているが、他のプログラミング言語でも応用可能である。
for文による変換の問題点
まず、よくあるfor文での配列変換パターンを見てみる。
ユーザー一覧から、名前だけを抽出する処理を考える。
interface User {
id: number;
name: string;
email: string;
}
// ユーザー一覧
const users: User[] = [
{ id: 1, name: "田中太郎", email: "tanaka@example.com" },
{ id: 2, name: "鈴木花子", email: "suzuki@example.com" },
{ id: 3, name: "佐藤次郎", email: "sato@example.com" },
];
// for文での実装
const names: string[] = [];
for (let i = 0; i < users.length; i++) {
// names配列に順次追加していく
names.push(users[i].name);
} このコードには以下の問題がある。
1. イミュータブルではない
names配列をletや空配列で宣言し、ループ内でpush()で要素を追加している。
変数が可変であるため、意図しない箇所で配列が変更されるリスクがある。
2. 副作用が存在する前提のコードである
ループの外で宣言された変数を内部で変更している。
このような副作用は、コードの追跡を困難にし、バグの原因になりやすい。
3. 冗長である
「配列を変換する」という単純な処理に対して、記述量が多くなっている。
インデックス変数iの管理、配列の長さの参照、要素へのアクセスなど、本質的でないコードが含まれている。
map()による変換
同じ処理をmap()で書き換えてみる。
interface User {
id: number;
name: string;
email: string;
}
const users: User[] = [
{ id: 1, name: "田中太郎", email: "tanaka@example.com" },
{ id: 2, name: "鈴木花子", email: "suzuki@example.com" },
{ id: 3, name: "佐藤次郎", email: "sato@example.com" },
];
// map()での実装
const names: string[] = users.map((user) => user.name); map()を使うことで、多くのメリットが得られる。
1. イミュータブルである
map()は元の配列を変更せず、新しい配列を返す。
constで宣言でき、変数が再代入されることはない。
2. 宣言的である
「何をするか」が明確である。
map()という名前自体が「配列の各要素を変換して新しい配列を作る」という意図を表している。
3. 簡潔に書ける
1行で記述でき、インデックス管理やpush()などの冗長なコードが不要である。
4. 型安全である
TypeScriptでは、map()の戻り値の型が自動的に推論される。コールバック関数の戻り値の型から、結果の配列の型が決まる。
map()の考え方
map()は、データの1:1変換を行うときに最適である。
前の例では、「ユーザーオブジェクト→ユーザー名」の1:1変換を行っている。
変換と副作用を分離する
map()のコールバック関数は「入力を受け取り、出力を返す」だけに徹するべきである。
外部変数への書き込みなどの副作用を含めてはいけない。
ここではnameがオプショナルなケースを考える。
interface User {
id: number;
name?: string; // オプショナル
email: string;
} ■副作用があるコード
const errors = new Set<string>();
const names = users.map((user) => {
if (!user.name) {
errors.add(`User ${user.id} has no name`); // 副作用
}
return user.name ?? "Unknown";
}); このコードは「変換」と「エラー収集」という2つのことを同時にやっている。
■副作用がないコード
const names = users.map((user) => user.name ?? "Unknown"); 副作用を含めるとコードの予測可能性が下がり、テストも困難になる。
変換は変換、副作用は副作用として、責務を明確に分けることが重要である。
それでもfor文を使いたくなった場合
変換しながら別の処理も実行したい場合
ユーザーオブジェクトをユーザー名のリストに変換しながら
ユーザー名がなかった場合はエラーを蓄積したい場合。
const errors = new Set<string>();
const names: string[] = [];
for (const user of users) {
if (!user.name) {
// ユーザー名が存在しない場合、エラーを蓄積する
errors.add(`User ${user.id} has no name`);
}
names.push(user.name ?? "Unknown");
このコードは「変換」と「エラー収集」という2つのことを同時にやっていることが設計上の問題である。
2つのことを一度にやらず、それぞれを分離して書くべきである。
// 変換のみを行う
const names = users.map((user) => user.name ?? "Unknown");
// バリデーションは別の責務として切り出す
function validateUsers(users: User[]): Set<string> {
const errorMessages = users
.filter((user) => !user.name)
.map((user) => `User ${user.id} has no name`);
return new Set(errorMessages);
}
const errors = validateUsers(users) filter()については別の記事で詳しく解説する。
ループが2回走ることになるが、現代の環境ではそこまで大きなパフォーマンスデメリットにはならない。
それよりも、コードの責務が明確になることのメリットの方が大きい。
順次非同期処理で変換したい場合
■改善前
const results: Response[] = [];
for (const url of urls) {
// 外部へのリクエストなど
const response = await fetchAndTransform(url);
results.push(response);
} これは多くの場合、Promise.all() + map()で並列化できる。
■改善後
const results = await Promise.all(
urls.map((url) => fetchAndTransform(url))
); ただし、この改善は順次処理を並列処理に変えている点に注意が必要である。
レート制限のあるAPIや、順序に依存する処理では、順次実行が必要な場合もある。
まとめ
1:1変換において、for文が本当に必要なケースはほぼ存在しない。
for文を使いたくなったら、それは設計を見直すサインである。
配列の変換には、for文ではなくmap()を使おう。
map()を使いこなすことで、より安全で読みやすく、保守しやすいコードを書くことができる。