JavaScriptの中級者がつまずく原因の多くは、文法を知らないことではなく「なぜ意図どおり動かないのか」を切り分けられないことです。バグ修正(デバッグ)とは、意図どおりに動かないコードの原因を特定して正しく直す作業のことで、実務のプログラミングで最も時間を使う工程です。
本記事は、基礎文法を終えた中級者向けに、実務で頻出する典型的なバグを自分で見つけて直す練習問題集です。var/letのスコープ、配列の破壊的変更、awaitし忘れ、==の型変換など、全12問を「壊れたコード → ヒント → 解答と解説」の形式で収録しました。基礎文法がまだの方は JavaScript練習問題23選(基礎文法編) から始めてください。
JavaScript練習問題シリーズ
テーマ別の練習問題はこちら。配列メソッドや非同期処理そのものを集中的に解きたい場合は、各専門記事へ進んでください。
- 基礎文法 23問(変数・関数・配列・条件分岐)
- DOM操作 10問(要素取得・イベント・動的生成)
- 配列メソッド 10問(map / filter / reduce など)
- 非同期処理 10問(Promise / async / await / fetch)
- バグ修正・デバッグ 12問(この記事)
この問題集の使い方と対象レベル
対象レベルは、基礎文法(変数・関数・配列・条件分岐)を理解済みの中級者です。各問は実際のコードに潜むバグを題材にしています。次の手順で取り組むと、デバッグ力が効率よく身につきます。
- まず解答を見ずに、壊れたコードの「どこが・なぜ」おかしいかを予想する
- ヒントを開いて、考える方向が合っているか確認する
- 解答例と解説で「なぜそうなるのか」と「次の一手」まで理解する
コードはブラウザの開発者ツール(DevTools)のコンソールに貼り付けて、実際に動かしながら確認すると理解が深まります。
【基本編】中級者がやりがちなバグ 5問
問1. ループ内の setTimeout が同じ値を出力する
このコードは1秒以内に 1, 2, 3 を出力する想定ですが、実際には 4, 4, 4 と表示されます。原因を見つけて直してください。
for (var i = 1; i <= 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 期待: 1, 2, 3 / 実際: 4, 4, 4ヒント
var はどのスコープを持つでしょうか。setTimeout のコールバックが実行されるのは、ループが終わった後です。
解答例と解説
for (let i = 1; i <= 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 出力: 1, 2, 3なぜ:var は関数スコープのため、ループ全体で同じ i を共有します。setTimeout のコールバックが実行される頃には i はループ終了後の値(4)になっています。let はブロックスコープで、反復ごとに新しい束縛を作るため期待どおり動きます。
次の一手:let を使わずに、即時関数(IIFE)で各回の i を引数として閉じ込める書き方でも解決できます。両方書いて挙動を比べてみましょう。
問2. 元の配列が知らないうちに書き換わる
並び替えた結果を sorted に入れたつもりが、元の scores まで並び替わってしまいます。原因を見つけて直してください。
const scores = [80, 50, 90, 70];
const sorted = scores.sort((a, b) => b - a);
console.log(scores); // 期待: [80, 50, 90, 70] のままヒント
sort() は新しい配列を返すのでしょうか、それとも元の配列を変更するのでしょうか。
解答例と解説
const scores = [80, 50, 90, 70];
const sorted = [...scores].sort((a, b) => b - a);
console.log(scores); // [80, 50, 90, 70]
console.log(sorted); // [90, 80, 70, 50]なぜ:sort() は元の配列を破壊的に並び替え、その配列自身を返します。元データを保持したいときは、スプレッド構文 [...scores] でコピーしてから sort します。reverse() も同じく破壊的です。
次の一手:非破壊版の toSorted()(ES2023)に書き換え、対応環境を確認してみましょう。配列操作をさらに練習したい人は 配列メソッド10問 へ。
問3. == による型変換で意図しない一致が起きる
APIから文字列で返ってきた status が、数値の 1 と一致してしまいます。文字列ではなく「数値の1」のときだけ通したい場合、どう直せばよいでしょうか。
const status = '1'; // APIから文字列で返ってきた
if (status == 1) {
console.log('有効'); // 文字列の '1' でも通ってしまう
}ヒント
== は比較の前に何をするでしょうか。型まで含めて比較したいときに使う演算子は?
解答例と解説
const status = '1';
if (Number(status) === 1) {
console.log('有効');
}なぜ:== は型変換してから比較するため '1' == 1 は true になります。意図しない一致はバグの温床です。=== は型も含めて厳密に比較します。文字列の数値を比べるときは Number() で明示的に変換してから === しましょう。
次の一手:status が "01" や " 1 " のとき Number() がどう振る舞うか確認し、入力バリデーションを足してみましょう。
問4. オブジェクトをコピーしたのに元が変わる
スプレッド構文でコピーした copy の設定を変えただけなのに、元の original まで変わってしまいます。原因を見つけて直してください。
const original = { name: '太郎', settings: { theme: 'dark' } };
const copy = { ...original };
copy.settings.theme = 'light';
console.log(original.settings.theme); // 期待: 'dark'ヒント
スプレッド構文は何階層までコピーするでしょうか。ネストしたオブジェクトはどう扱われますか。
解答例と解説
const original = { name: '太郎', settings: { theme: 'dark' } };
const copy = { ...original, settings: { ...original.settings } };
copy.settings.theme = 'light';
console.log(original.settings.theme); // 'dark'なぜ:スプレッド構文は1階層だけの浅いコピーです。ネストしたオブジェクトは参照が共有されるため、コピー先の変更が元へ波及します。ネストごとに展開するか、structuredClone() で深いコピーを作ります。
次の一手:structuredClone(original) に書き換え、関数やDOMを含むオブジェクトでは使えない制約も調べてみましょう。
問5. 0.1 + 0.2 が 0.3 にならない
合計が 0.3 になるはずなのに「不正解」と表示されます。原因を見つけて、正しく判定できるよう直してください。
const total = 0.1 + 0.2;
if (total === 0.3) {
console.log('正解');
} else {
console.log('不正解: ' + total); // 0.30000000000000004
}ヒント
小数を2進数で表すと何が起きるでしょうか。小数同士を === で比べるのは安全でしょうか。
解答例と解説
const total = 0.1 + 0.2;
if (Math.abs(total - 0.3) < Number.EPSILON) {
console.log('正解');
}なぜ:2進数の浮動小数点では 0.1 や 0.2 を正確に表せず、計算に微小な誤差が出ます。小数の比較は Math.abs で誤差範囲(イプシロン)を許容するか、整数(最小単位)に直して計算します。
次の一手:金額計算を「円 → 銭」のように整数化して扱う方式に書き換え、誤差が出ないことを確認してみましょう。
【応用編】非同期・スコープのバグ 5問
問6. await を忘れて Promise を操作してしまう
ユーザー情報を取得して返す関数ですが、res.json is not a function のエラーや undefined が返ります。原因を見つけて直してください。
async function getUser() {
const res = fetch('/api/user');
const data = res.json();
return data;
}ヒント
fetch() と res.json() はそれぞれ何を返すでしょうか。
解答例と解説
async function getUser() {
const res = await fetch('/api/user');
const data = await res.json();
return data;
}なぜ:fetch() も res.json() も Promise を返します。await を付けないと、解決前の Promise を操作してしまい、エラーや undefined になります。await は async 関数の中でのみ使えます。
次の一手:response.ok を見て失敗時に throw し、try/catch で受ける処理を足しましょう。非同期をもっと練習したい人は 非同期処理10問 へ。
問7. forEach の中の await が待たれない
すべての保存が終わってから「完了」を出したいのに、保存より先に「完了」が表示されます。原因を見つけて直してください。
async function saveAll(items) {
items.forEach(async (item) => {
await save(item);
});
console.log('完了'); // save より先に出る
}ヒント
forEach はコールバックが返した Promise を待ってくれるでしょうか。
解答例と解説
async function saveAll(items) {
for (const item of items) {
await save(item);
}
console.log('完了');
}なぜ:forEach はコールバックの Promise を待たないため、ループ直後の処理が先に走ります。順番に待ちたいときは for...of を使います。
次の一手:順序が不要なら await Promise.all(items.map(item => save(item))) で並列化できます。順次実行との速度差を体感してみましょう。
問8. this が消えて count が NaN になる
ボタンをクリックすると count が増えるはずが、NaN になります。原因を見つけて直してください。
class Counter {
constructor() { this.count = 0; }
increment() { this.count++; console.log(this.count); }
}
const c = new Counter();
const btn = document.querySelector('button');
btn.addEventListener('click', c.increment); // this が btn になるヒント
メソッドを「参照」として渡したとき、呼び出し時の this は何を指すでしょうか。
解答例と解説
// 方法1: アロー関数で包む
btn.addEventListener('click', () => c.increment());
// 方法2: コンストラクタで束縛する
// constructor() { this.count = 0; this.increment = this.increment.bind(this); }なぜ:メソッドを参照として渡すと this の束縛が外れ、呼び出し元(ここでは btn 要素)が this になります。アロー関数で包むか bind(this) で束縛します。
次の一手:クラスフィールドで increment = () => {...} と定義する書き方に変えてみましょう。DOMイベントの練習は DOM操作10問 へ。
問9. catch で例外を握りつぶしている
データ取得に失敗しても画面が無反応で、何が起きたか分かりません。エラーが「消えてしまう」原因を見つけて直してください。
async function loadData() {
try {
const res = await fetch('/api/data');
return await res.json();
} catch (e) {
// 何もしない
}
}ヒント
catch で何もしないと、エラーと戻り値はどうなるでしょうか。呼び出し元はエラーに気づけますか。
解答例と解説
async function loadData() {
try {
const res = await fetch('/api/data');
if (!res.ok) throw new Error('HTTP ' + res.status);
return await res.json();
} catch (e) {
console.error('データ取得に失敗:', e);
throw e; // 呼び出し元に伝える
}
}なぜ:catch で何もしないと、エラーが消えて undefined が返り、原因究明が困難になります(サイレント障害)。最低限ログを残し、必要なら再 throw して呼び出し元に判断を委ねます。
次の一手:呼び出し側で「読み込みに失敗しました」とUIに表示する分岐を足し、ユーザーに状態が伝わるようにしてみましょう。
問10. 配列の最後で undefined が出る
配列の中身を順に出力したいのに、最後に undefined が混ざります。原因を見つけて直してください。
const arr = [10, 20, 30];
for (let i = 0; i <= arr.length; i++) {
console.log(arr[i]); // 10, 20, 30, undefined
}ヒント
配列のインデックスは 0 から始まると、最後の要素の添字はいくつでしょうか。length の値と比べてみましょう。
解答例と解説
const arr = [10, 20, 30];
for (let i = 0; i < arr.length; i++) {
console.log(arr[i]); // 10, 20, 30
}なぜ:配列のインデックスは 0 から length - 1 までです。<= にすると存在しない添字(length)にアクセスして undefined になります。境界条件のずれ(off-by-oneエラー)は頻出バグです。
次の一手:for...of や forEach に書き換えると、添字ミスそのものをなくせます。どれが読みやすいか比べてみましょう。
【総合問題】複数のバグを一度に直す 2問
総合1. スコア上位者を返す関数の複合バグ
APIからプレイヤー一覧を取得し、スコア順に並べて勝者だけを返す関数です。4つのバグが潜んでいます。すべて見つけて直してください。
async function getTopScorers(url) {
const res = fetch(url);
const players = res.json();
players.sort((a, b) => b.score - a.score);
const winners = players.filter(p => p.win == 1);
return winners;
}ヒント
これまでの問1〜9の知識が複合しています。①await、②エラー処理、③破壊的変更、④型変換、の4観点で点検しましょう。
解答例と解説
async function getTopScorers(url) {
const res = await fetch(url);
if (!res.ok) throw new Error('HTTP ' + res.status);
const players = await res.json();
const sorted = [...players].sort((a, b) => b.score - a.score);
const winners = sorted.filter(p => Number(p.win) === 1);
return winners;
}なぜ:1つの関数に①await忘れ(問6)②エラー処理なし(問9)③破壊的 sort(問2)④緩い == 比較(問3)が同居しています。実務のバグは複合的に絡みます。await → エラー処理 → 非破壊 → 厳密比較の順で1つずつ潰すのが定石です。
次の一手:モックデータでこの関数の単体テストを書き、修正前後で結果が変わることを確認してみましょう。
総合2. カート合計の計算が NaN・文字列になる
カート内の金額を合計する処理ですが、数値ではなく [object Object]... のような文字列が出力されます。原因を見つけて直してください。
const cart = [
{ name: 'A', price: 980, qty: 2 },
{ name: 'B', price: 1200, qty: 1 }
];
const total = cart.reduce((sum, item) => sum + item.price * item.qty);
console.log(total); // 期待: 3160ヒント
reduce に初期値を渡さないと、最初の sum には何が入るでしょうか。
解答例と解説
const total = cart.reduce((sum, item) => sum + item.price * item.qty, 0);
console.log(total); // 3160なぜ:reduce に初期値を渡さないと、最初の要素(オブジェクト)が初期の sum になり、オブジェクト + 数値 で文字列化してしまいます。数値の集計では必ず初期値 0 を渡します。
次の一手:割引やクーポンを加えた計算に拡張し、税込み計算で浮動小数点の誤差(問5)が出ないか検証してみましょう。
よくある質問(FAQ)
Q. バグ修正の練習にはどんな効果がありますか?
コードを「読める」段階から「自分で原因を切り分けて直せる」段階へ進めます。実務のプログラミングは新規実装よりデバッグに使う時間が長く、原因特定力がそのまま開発速度に直結します。
Q. 中級者になるには何から始めればいいですか?
基礎文法を終えたら、配列メソッド・非同期処理・DOM操作の3領域を一通り触り、本記事のような典型バグを自力で直せるようにするのが近道です。各領域の練習問題はシリーズ一覧から進められます。
Q. console.log 以外のデバッグ方法はありますか?
ブラウザの開発者ツール(DevTools)の Sources パネルでブレークポイントを置き、変数の値を1行ずつ確認する方法が強力です。コード中に debugger と書くと、その行で自動的に停止します。
Q. == と === はどちらを使うべきですか?
原則として === を使います。== は型変換を伴うため '1' == 1 が true になるなど予期しない一致が起き、バグの原因になります。null と undefined をまとめて判定したいときだけ意図的に == null を使う運用が安全です。
Q. 非同期処理のバグを減らすコツはありますか?
Promiseを返す関数の前には必ず await を付ける、ループ内の非同期は forEach ではなく for...of か Promise.all を使う、catch で握りつぶさずログと再throwを行う——この3点で大半を防げます。
Q. 問題はどの順番で解くのがおすすめですか?
基本編(問1〜5)でスコープ・型・コピーの基礎を固め、応用編(問6〜10)で非同期と this に進み、最後に総合問題で複数バグの複合を体験する順番が効果的です。
関連記事と次のステップ
バグを直す力がついたら、次はテーマ別の練習問題で「書く力」を伸ばしましょう。
体系的に学び直したい場合は学習ガイドからどうぞ。
実装・制作のご相談
「練習はできたけれど、実際の案件で動くコードに落とし込むのが不安」——そんなときは、Web制作・開発のプロに相談するのも一つの手です。フロントエンドの実装やバグ調査の依頼は、制作チーム RINIA で承っています。
