プログラマのための IT 教科書

JavaScript の基礎

この部の 1 / 9 章 ・ 全体で 34 / 76 章 ・ 読了目安 55 分

この章を読むとできるようになること
  • 実行時に何が起きているかを説明できる
  • 非同期処理を直列と並列で書き分けられる
  • 破壊的な操作と参照の共有による事故を避けられる

TypeScript は、JavaScript に型を足したものです。 ビルドすると型は消え、実際に動くのは JavaScript です。

あなたが書く TypeScript
      ↓ ビルド(型を取り除くだけ)
実行される JavaScript

つまり、実行時の挙動はすべて JavaScript の挙動です。 型が付いていても、0.1 + 0.2 !== 0.3 は変わりませんし、 [1, 10, 2].sort() は [1, 10, 2] のままです(後述)。

次の章で型を扱う前に、土台になる JavaScript を押さえます。

値には7つの基本型がある

'hello'          // string
42               // number  ← 整数と小数の区別が無い。すべて64bit浮動小数点
10n              // bigint  ← 大きな整数用
true             // boolean
undefined        // 値が入っていない(宣言されたが未代入)
null             // 「無い」ことを明示的に表す
Symbol('id')     // symbol(ほぼ使わない)

これ以外はすべてオブジェクトです。配列も関数も、実体はオブジェクトです。

number は1種類しかない

JavaScript には int がありません。すべて64bit の浮動小数点です。

0.1 + 0.2          // 0.30000000000000004(ビットとバイト)
9007199254740993   // 9007199254740992   ← 精度が足りず、値が変わる

安全に扱える整数は約 9007兆(2⁵³) までです。 Go の int64 の値を JSON で受け取ると壊れる可能性があるので、 大きな ID は文字列でやり取りするのが実務の定石です (protobuf の int64 が JSON では文字列になるのは、これが理由です)。

undefined と null の使い分け

意味いつ出るか
undefinedまだ値が無い未代入の変数、無いプロパティ、戻り値の無い関数
null意図的に「無い」人が明示的に入れた時だけ

実務では「null は自分で入れる、undefined は勝手に出てくる」と考えると整理できます。 チームで片方に統一するのが一般的です。

変数は const を使う

const x = 1;   // 再代入できない(基本これ)
let y = 2;     // 再代入する時だけ
var z = 3;     // 使わない

var を使わない理由は、スコープの単位がブロックではなく関数だからです。

if (true) { var a = 1; let b = 2; }
console.log(a);   // 1     ← ブロックの外から見えてしまう
console.log(b);   // エラー ← 正しい挙動
`const` は「中身が変わらない」ではない

const が保証するのは再代入できないことだけです。

const user = { name: 'sato' };
user.name = 'tanaka';   // これは通る
user = {};              // これはエラー

中身まで固定したい場合は Object.freeze か、 TypeScript の readonly(次章)を使います。

比較は === を使う

== は型を勝手に変換してから比較します。

0 == '0'        // true   ← 文字列が数値に変換される
0 == ''         // true
0 == false      // true
null == undefined  // true
'' == '0'       // false  ← 直感に反する

規則が複雑で覚える価値がないので、常に === を使います。 唯一の例外は x == null(null と undefined の両方を判定する)で、 これは慣用句として許容されることがあります。

falsy な値

if (x) で偽と判定される値は、次の8つです。

false, 0, -0, 0n, '', null, undefined, NaN
`if (count)` は 0 を弾いてしまう
function render(count) {
  if (!count) return '件数がありません';   // count === 0 でもここに来る
  return `${count} 件`;
}

0 と空文字は「値が無い」ではありません。

if (count === undefined) ...        // 明示的に書く
const n = count ?? 0;               // ?? は null / undefined の時だけ既定値を使う
const m = count || 0;               // || は 0 や '' も置き換えてしまう

??(null 合体演算子)と || の違いは、実務でよく効きます。

NaN は自分と等しくない

NaN === NaN         // false
Number.isNaN(NaN)   // true   ← 判定にはこれを使う

Number('abc') や 0/0 の結果が NaN です。 数値に変換できなかったことに気づかず計算を続けるのが、よくあるバグです。

オブジェクトと配列は「参照」

const a = { n: 1 };
const b = a;
b.n = 2;
console.log(a.n);   // 2   ← 同じものを指している

関数に渡した場合も同じです。中身を書き換えると呼び出し元にも影響します。

function addTax(items) {
  items.forEach((i) => { i.price *= 1.1; });   // 引数を破壊している
}

これは「引数を渡しただけなのに元の配列が変わっている」という 追いにくいバグを生みます。新しい配列を返すのが原則です。

const withTax = items.map((i) => ({ ...i, price: Math.floor(i.price * 1.1) }));
スプレッドは浅いコピー
const copy = { ...original };
copy.tags.push('x');   // original.tags も変わる(同じ配列を指している)

{ ...obj } は1階層だけコピーします。 入れ子まで含めて複製したい場合は structuredClone(obj) を使います (JSON.parse(JSON.stringify(obj)) は Date や undefined が壊れるため非推奨)。

関数

function f(a, b = 10) { return a + b; }      // 関数宣言(巻き上げられる)
const g = (a, b = 10) => a + b;              // アロー関数(式)
const h = (...args) => args.length;          // 可変長引数

関数は値なので、変数に入れたり、引数として渡したりできます。 これが map や filter にコールバックを渡せる理由です。

クロージャ — 関数が外の変数を覚えている

function counter() {
  let count = 0;
  return () => ++count;    // 外側の count を「閉じ込めて」いる
}
const next = counter();
next();   // 1
next();   // 2

counter() はもう終わっているのに、count は生き続けます。 返された関数が参照しているからです(プロセスとメモリの GC の話につながります)。

クロージャが役に立つ場面

実務で意識的に使うのは、設定を先に固定した関数を作る時です。

const withRetry = (times) => async (fn) => {
  for (let i = 0; i < times; i++) {
    try { return await fn(); } catch (e) { if (i === times - 1) throw e; }
  }
};
const retry3 = withRetry(3);

this は呼び出し方で決まる

JavaScript で最も混乱するところです。this は定義場所ではなく、呼び出し方で決まります。

const obj = {
  name: 'sato',
  hello() { return this.name; },
};
obj.hello();              // 'sato'   (obj.  の形で呼んだから)
 
const fn = obj.hello;
fn();                     // undefined(呼び出し元が消えた)

アロー関数は自分の this を持たず、書かれた場所の this を使います。 これが「コールバックはアロー関数で書く」という慣習の理由です。

class Timer {
  constructor() { this.count = 0; }
  start() {
    setInterval(() => { this.count++; }, 1000);       // 動く
    setInterval(function () { this.count++; }, 1000); // 動かない(this が違う)
  }
}

実務では、メソッドをそのまま値として渡さない、 コールバックはアロー関数で書くの2つを守れば、ほぼ事故りません。

配列メソッド

for を書く前に、これらで書けないか考えます。

const users = [{ name: 'a', age: 20 }, { name: 'b', age: 30 }];
 
users.map((u) => u.name);            // 変換 → ['a', 'b']
users.filter((u) => u.age >= 25);    // 絞り込み
users.find((u) => u.name === 'a');   // 最初の1件(無ければ undefined)
users.some((u) => u.age > 25);       // 1件でも該当するか → boolean
users.every((u) => u.age > 10);      // 全部該当するか
users.reduce((sum, u) => sum + u.age, 0);   // 畳み込み → 50
users.flatMap((u) => [u.name, u.age]);      // 変換して平坦化
Object.groupBy(users, (u) => u.age >= 25 ? 'senior' : 'junior');  // 分類

破壊的か、そうでないか

元の配列を書き換えるメソッドがあります。 ここは覚えてください。

破壊する破壊しない(新しい配列を返す)
sort reverse splice push pop shift unshift fillmap filter slice concat toSorted toReversed
const a = [3, 1, 2];
const b = a.sort();     // a も [1,2,3] に変わる(b と a は同じ配列)
const c = a.toSorted(); // a は変わらない(新しい配列)
`sort()` は文字列として比較する

これは非常によく踏みます。

[1, 10, 2, 20].sort();          // [1, 10, 2, 20]  ← 文字列順
[1, 10, 2, 20].sort((a, b) => a - b);   // [1, 2, 10, 20]

デフォルトの sort は要素を文字列に変換して比較します。 数値をソートする時は必ず比較関数を渡してください。

便利な記法

const { name, age = 0 } = user;              // 分割代入(既定値つき)
const [first, ...rest] = list;               // 配列の分割代入
const merged = { ...defaults, ...options };  // スプレッド(後勝ち)
 
user?.profile?.avatar        // オプショナルチェイニング(途中が null/undefined なら undefined)
user.name ?? '名無し'         // null 合体(null/undefined の時だけ既定値)
`${name} さん(${age}歳)`     // テンプレートリテラル

?. と ?? は、null チェックのネストを消せるので実務で多用します。

非同期処理

これが JavaScript で最も重要な部分です。

ブラウザはなぜ動くのかで見たとおり、JavaScript はシングルスレッドです。 時間のかかる処理(通信・ファイル読み込み)で待ってしまうと、 その間なにもできません。だから待たずに、終わったら教えてもらう方式を取ります。

Promise

fetch('/api/users')            // Promise を返す(まだ結果は無い)
  .then((res) => res.json())   // 成功したら次へ
  .then((data) => console.log(data))
  .catch((err) => console.error(err))   // どこかで失敗したらここ
  .finally(() => console.log('完了'));

Promise は「いつか結果が入る箱」で、3つの状態を持ちます。

pending(処理中) → fulfilled(成功)
                  → rejected(失敗)

async / await

then の連鎖は読みにくいので、同期のコードのように書ける記法が用意されています。

async function loadUsers() {
  try {
    const res = await fetch('/api/users');
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return await res.json();
  } catch (err) {
    logger.error({ err }, 'ユーザーの取得に失敗');
    throw err;
  }
}
  • async を付けた関数は、必ず Promise を返します
  • await は Promise の結果が出るまで、その関数の中だけ待ちます
  • await は async 関数の中か、モジュールのトップレベルでのみ使えます
`fetch` は 404 でも reject しない
const res = await fetch('/api/nope');   // 404 でも例外にならない

fetch が失敗するのはネットワークに到達できなかった時だけです。 HTTP のステータスは自分で確認する必要があります(res.ok)。

これを忘れると、「エラーなのに成功として処理が進む」バグになります。

並列にする

これを知らないと、無駄に遅いコードを書きます。

// 直列: 合計 300ms(100 + 100 + 100)
const a = await fetchA();
const b = await fetchB();
const c = await fetchC();
 
// 並列: 合計 100ms(同時に始めて、全部揃うのを待つ)
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);

互いに依存しない処理は Promise.all でまとめます。

使い分け意味
Promise.all全部成功で成功。1つでも失敗したら即失敗
Promise.allSettled全部の結果を成否込みで受け取る
Promise.race最初に決着したものを採用(タイムアウトの実装に使う)
ループの中の `await`
// 遅い: 1件ずつ順番に待つ(1000件 × 50ms = 50秒)
for (const id of ids) {
  results.push(await fetchUser(id));
}

ただし、全部を一斉に投げるのも危険です。1000件同時に リクエストを送ると、相手のサービスを落とします(マイクロサービスの実務)。

同時実行数を制限しながら回すのが正解です。

// 10件ずつ処理する
for (let i = 0; i < ids.length; i += 10) {
  const chunk = ids.slice(i, i + 10);
  results.push(...await Promise.all(chunk.map(fetchUser)));
}
`forEach` の中で `await` は効かない
items.forEach(async (i) => { await save(i); });
console.log('完了');   // save が終わる前に出力される

forEach はコールバックの Promise を待ちません。 for...of を使うか、Promise.all(items.map(...)) にします。

実行順を予測する

ブラウザはなぜ動くのかのイベントループの話です。

console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
 
// 出力: 1, 4, 3, 2

同期コードが全部終わってから、Promise(マイクロタスク)が先、 その後に setTimeout(マクロタスク)が実行されます。

モジュール

// export する側
export const VERSION = '1.0';
export function hello() {}
export default class Server {}
 
// import する側
import Server, { hello, VERSION } from './server.js';
import * as server from './server.js';

これが ESM(ECMAScript Modules)で、現在の標準です。 Node.js には古い CommonJS(require / module.exports)もあり、 両者が混在しているのが、Node 界隈のややこしさの元です。

// CommonJS(古い方)
const { hello } = require('./server');
module.exports = { hello };

package.json の "type": "module" で ESM 扱いになります。 新規に書くなら ESM です。

循環 import

A が B を import し、B が A を import すると、 片方が初期化前の undefined を掴むことがあります。

エラーメッセージが undefined is not a function のような 分かりにくい形になるのが厄介です。

循環が起きたら、共通部分を3つ目のモジュールに切り出すのが定石です。 これは設計の基礎の依存の向きの話につながります。

`const ids = [3, 25, 100]; ids.sort();` の結果はどうなりますか。

実務の落とし穴まとめ

  1. == を使う — 常に ===
  2. if (count) — 0 と空文字を弾いてしまう。?? と || を区別する
  3. sort() に比較関数を渡さない — 文字列比較になる
  4. 破壊的メソッドに気づかない — sort reverse splice は元を変える
  5. スプレッドで深いコピーのつもり — 1階層だけ
  6. fetch が 404 で例外にならない — res.ok を必ず見る
  7. forEach の中で await — 待たれない
  8. ループの中で直列 await — 遅い。ただし一斉並列も危険
  9. 大きな整数を number で受ける — 2⁵³ を超えると壊れる。ID は文字列で
  10. this を持つメソッドを値として渡す — this が消える

まとめ

  • TypeScript は型を消せば JavaScript。実行時の挙動はすべてこの章の話
  • const を基本に、=== で比較する
  • オブジェクトと配列は参照。引数を破壊せず、新しい値を返す
  • this は呼び出し方で決まる。コールバックはアロー関数で書く
  • 配列メソッドは破壊的かどうかを意識する。sort は要注意
  • 非同期は Promise と async/await。fetch の失敗判定と、 Promise.all による並列化が実務の勘所
  • 実行順は 同期 → マイクロタスク(Promise)→ マクロタスク(setTimeout)
  • モジュールは ESM を使う。循環 import は設計の警告

公式ドキュメント

迷ったら一次情報に戻ってください。

対象リンク
MDN JavaScript ガイド(日本語)https://developer.mozilla.org/ja/docs/Web/JavaScript/Guide
MDN JavaScript リファレンスhttps://developer.mozilla.org/ja/docs/Web/JavaScript/Reference
ECMAScript 仕様https://tc39.es/ecma262/

章末問題

3つの API を順に await した処理が 900ms かかっています。3つは互いに独立しています。どう直しますか。

配列を受け取って合計金額に消費税を加える関数を書きます。最も安全なのはどれですか。

次は、この JavaScript に型を足します。

読み終わったら記録しておくと、目次で進み具合が分かります。