JavaScript Map/Set完全実践ガイド〜WeakMap/WeakSet・Object/Arrayとの使い分け・ES2025 Set演算【2026年版】〜

JavaScript の MapSet、そしてそのメモリに優しい派生形である WeakMap / WeakSet は、ECMAScript 2015 で導入されてから 10 年以上が経過した今でも「ObjectArray でなんとなく代用していて、本当の出番を理解していない」開発者が驚くほど多い領域です。「キーに文字列以外を使いたい」「重複を排除したい」「巨大データを高速に検索したい」「メモリリークを起こさずキャッシュしたい」――こうした実務要件は、すべて Map / Set 系で正しく解けます。

本記事では ECMAScript 2025 / Node.js 22+ / TypeScript 5.x 準拠で、コピペで動く 45 以上のコードサンプルを通して、Map の基本 API → Set 演算(union / intersection / difference / symmetricDifference)→ Map.groupBy / Object.groupBy(ES2024)→ WeakMap / WeakSet / WeakRef → Object / Array との使い分け基準 → パフォーマンス比較 → LRU キャッシュ・隣接リストグラフ・型安全 EventEmitter などの実装パターン → TypeScript の Map<K,V> 型まで、Map / Set 系コレクションの実践知見を完全網羅します。

本記事は「組込みコレクション(Map / Set)観点」に特化しているため、JavaScript Object 完全実践ガイド(プレーンオブジェクト観点)、JavaScript 配列メソッド完全ガイド(配列操作観点)、および Iterator/Generator 完全実践ガイド(走査プロトコル観点)と相互補完的に読むと、JavaScript のコレクション周りの基礎体力が一気に上がります。

  1. 1. Map / Set の存在意義と全体像
    1. 1.1 Map / Set / WeakMap / WeakSet の関係図
    2. 1.2 なぜ Object / Array で代用してはいけないのか
    3. 1.3 計算量(Big-O)の早見表
  2. 2. Map の基本 API を完全攻略
    1. 2.1 set / get / has / delete の挙動
    2. 2.2 コンストラクタで一括初期化
    3. 2.3 キーに任意の型を使う
    4. 2.4 size プロパティで件数を取得
  3. 3. Map のイテレーションとループ
    1. 3.1 entries / keys / values の使い分け
    2. 3.2 forEach コールバック
    3. 3.3 配列メソッドと組み合わせる
    4. 3.4 clear() で一括削除
  4. 4. Map ⇔ Object / JSON との相互変換
    1. 4.1 Object → Map
    2. 4.2 Map → Object
    3. 4.3 Map を JSON.stringify する
    4. 4.4 structuredClone で Map をディープコピー
  5. 5. Set の基本 API を完全攻略
    1. 5.1 add / has / delete
    2. 5.2 配列の重複排除イディオム
    3. 5.3 イテレーション
  6. 6. ES2025 Set 演算メソッド
    1. 6.1 union(和集合)
    2. 6.2 intersection(積集合)
    3. 6.3 difference / symmetricDifference
    4. 6.4 isSubsetOf / isSupersetOf / isDisjointFrom
    5. 6.5 Set 演算の引数は SetLike でよい
  7. 7. groupBy で集計を 1 行にする(ES2024)
    1. 7.1 Object.groupBy
    2. 7.2 Map.groupBy(任意キーで分類)
    3. 7.3 groupBy で実用集計
  8. 8. WeakMap / WeakSet / WeakRef でメモリリーク回避
    1. 8.1 WeakMap の基本
    2. 8.2 WeakMap でプライベートフィールド代替
    3. 8.3 WeakSet で「処理済みフラグ」を立てる
    4. 8.4 WeakRef + FinalizationRegistry で高度なキャッシュ
  9. 9. 実装パターン集
    1. 9.1 LRU キャッシュ
    2. 9.2 隣接リスト形式のグラフ
    3. 9.3 多値マップ(MultiMap)
    4. 9.4 メモ化(memoize)
    5. 9.5 デバウンス(コンポーネントごと)
    6. 9.6 型安全な EventEmitter
    7. 9.7 簡易ハッシュテーブルの自前実装
  10. 10. パフォーマンスと使い分けの最終判断
    1. 10.1 Map vs Object: ベンチマーク
    2. 10.2 Set vs Array.includes
    3. 10.3 使い分けチートシート
    4. 10.4 TypeScript で型を厳格化する
    5. 10.5 学習リソースとキャリアパス
    6. 10.6 まとめ

1. Map / Set の存在意義と全体像

Object をキー・値ストアとして使えばいいじゃないか」「Array から重複を消すだけならフィルタで十分」――この発想で書かれたコードは、データ量が増えた瞬間に O(n) 検索やプロトタイプ汚染キーの文字列強制といった問題に直撃します。Map / Set は、そうした落とし穴を最初から避けて設計された専用コレクションです。

1.1 Map / Set / WeakMap / WeakSet の関係図

4 つは「強参照 vs 弱参照」「キー・値 vs 値のみ」の 2 軸で整理できます。WeakMap / WeakSet はキーがオブジェクト限定かつ列挙不可という強い制約と引き換えに、ガベージコレクションを邪魔しません。

// 4 コレクションの関係を一行で比較
// Map     : 任意のキー(プリミティブ+オブジェクト)/ 強参照 / 列挙可
// Set     : 値のみ / 強参照 / 列挙可
// WeakMap : オブジェクトキー限定 / 弱参照 / 列挙不可
// WeakSet : オブジェクト値限定 / 弱参照 / 列挙不可

const map = new Map();         // ✅ key: any, value: any
const set = new Set();         // ✅ value: any
const wmap = new WeakMap();    // ⚠ key: object only
const wset = new WeakSet();    // ⚠ value: object only

1.2 なぜ Object / Array で代用してはいけないのか

もっとも分かりやすい違いはキーの型です。Object はキーを暗黙的に文字列(または Symbol)へ変換するため、1"1" が衝突します。Map はキーをSameValueZero アルゴリズムで厳密比較するため、型ごとに別キーとして扱えます。

// Object: キーは文字列に強制される
const obj = {};
obj[1] = "number key";
obj["1"] = "string key"; // 上書きされる!
console.log(obj[1]);     // "string key"
console.log(Object.keys(obj)); // ["1"] ← 1 件しかない

// Map: 型ごとに別キー
const m = new Map();
m.set(1, "number key");
m.set("1", "string key");
console.log(m.get(1));   // "number key"
console.log(m.get("1")); // "string key"
console.log(m.size);     // 2 ← 別キー扱い

1.3 計算量(Big-O)の早見表

Map / Set の get / has / set / add / delete は仕様上「平均的にサブリニア」と規定されており、各エンジン(V8 / SpiderMonkey / JavaScriptCore)で実質 O(1) のハッシュテーブル実装になっています。Array の indexOf / includes は O(n) なので、検索が頻繁な用途では Map / Set が圧勝します。

// 計算量の比較(典型値)
// 操作              Array      Object     Map       Set
// has / includes    O(n)       O(1)*      O(1)*     O(1)*
// add / set         O(1) push  O(1)*      O(1)*     O(1)*
// delete            O(n)       O(1)*      O(1)*     O(1)*
// 順序保証          ✅         ⚠仕様変動   ✅挿入順    ✅挿入順
// キー任意型        ❌(index)  ❌(string) ✅         -
// イテレーション    ✅         Object.keys ✅        ✅
// (*) はハッシュテーブル前提の実質 O(1)

2. Map の基本 API を完全攻略

Map の API は驚くほどシンプルで、覚えるべきメソッドは set / get / has / delete / clear / size / entries / keys / values / forEach の 10 個だけです。これだけで実務の 95% は片付きます。

2.1 set / get / has / delete の挙動

setメソッドチェーンできるよう Map インスタンス自身を返します。get は未登録キーに対して undefined を返すため、有無の判定には必ず has を使うのが安全です(値が undefined でも区別できる)。

// set / get / has / delete の基本
const userScore = new Map();

userScore.set("alice", 85)
         .set("bob", 92)
         .set("carol", 78); // メソッドチェーン可能

console.log(userScore.get("alice"));    // 85
console.log(userScore.has("dave"));     // false
console.log(userScore.get("dave"));     // undefined ← 未登録と値 undefined を区別できない

userScore.set("dave", undefined);
console.log(userScore.has("dave"));     // true ← has なら区別できる
console.log(userScore.get("dave"));     // undefined

userScore.delete("bob");
console.log(userScore.has("bob"));      // false

2.2 コンストラクタで一括初期化

[key, value] の配列を要素にもつ iterable を渡せば、Map を 1 行で初期化できます。Object.entries() の結果や、他の Map もそのまま流し込めるのが強みです。

// 配列リテラルからの初期化
const colors = new Map([
  ["red",   "#ff0000"],
  ["green", "#00ff00"],
  ["blue",  "#0000ff"],
]);
console.log(colors.get("green")); // "#00ff00"

// Object.entries 経由(Object → Map)
const config = { host: "localhost", port: 8080, ssl: true };
const configMap = new Map(Object.entries(config));
console.log(configMap.get("port")); // 8080

// Map → 新しい Map(浅いコピー)
const clone = new Map(configMap);
console.log(clone.size); // 3

2.3 キーに任意の型を使う

Map のキーはプリミティブもオブジェクトも関数も使えます。これは WeakMap 不要の通常用途でも便利で、たとえば「DOM ノードに付随するメタデータを保持する」「関数オブジェクトをキーにメモ化する」といった用途で威力を発揮します。

// オブジェクトをキーに使う
const meta = new Map();
const node1 = { id: 1 };
const node2 = { id: 2 };

meta.set(node1, { lastSeen: Date.now() });
meta.set(node2, { lastSeen: 0 });

console.log(meta.get(node1)); // { lastSeen: ... }
console.log(meta.get({ id: 1 })); // undefined ← 参照が違うと別キー

// 関数をキーに使う(コールバック管理など)
const handlers = new Map();
const onClick = () => console.log("click");
handlers.set(onClick, { registeredAt: Date.now() });
console.log(handlers.has(onClick)); // true

// NaN もキーになる(SameValueZero では NaN === NaN とみなす)
const m = new Map();
m.set(NaN, "not a number");
console.log(m.get(NaN)); // "not a number"

2.4 size プロパティで件数を取得

Object には件数を返す組込みプロパティがなく Object.keys(o).length を毎回計算する必要がありますが、Map には size ゲッターが用意されています。O(1) で件数が取れる点も実用上大きい差です。

// size は O(1) で取得可能
const m = new Map();
for (let i = 0; i < 100_000; i++) m.set(i, i * 2);
console.log(m.size); // 100000(瞬時)

// Object の場合は内部で全キーを列挙するため O(n)
const o = {};
for (let i = 0; i < 100_000; i++) o[i] = i * 2;
console.log(Object.keys(o).length); // 100000(O(n))

3. Map のイテレーションとループ

Map は挿入順を保持する Iterableです。for...of / forEach / entries / keys / values のいずれでも回せ、配列メソッドと組み合わせやすい構造になっています。

3.1 entries / keys / values の使い分け

3 つはすべて MapIterator を返します。Map をそのまま for...of に渡すと entries() と同等です([key, value] ペアが返る)。

// entries / keys / values
const m = new Map([
  ["a", 1], ["b", 2], ["c", 3]
]);

for (const [k, v] of m) {
  console.log(k, v); // "a" 1, "b" 2, "c" 3
}

for (const k of m.keys())   console.log(k);     // "a", "b", "c"
for (const v of m.values()) console.log(v);     // 1, 2, 3
for (const e of m.entries()) console.log(e);    // ["a",1], ["b",2], ["c",3]

// Array.from で配列化
const keys = [...m.keys()];   // ["a","b","c"]
const vals = [...m.values()]; // [1,2,3]

3.2 forEach コールバック

Map の forEach は引数の順序が (value, key, map) です。Array の (value, index, array) と揃っており、キーが第 2 引数である点だけ注意してください。

// Map#forEach は (value, key, map) の順
const stock = new Map([
  ["apple", 10], ["banana", 5], ["cherry", 30]
]);

stock.forEach((value, key) => {
  console.log(`${key}: ${value}個`);
});
// apple: 10個
// banana: 5個
// cherry: 30個

3.3 配列メソッドと組み合わせる

Map 自体には map / filter / reduce がありません。Array.from やスプレッドで配列化してから配列メソッドを使うのが定番です(ES2025 の Iterator Helpers で直接適用も可能)。

// filter → Map に戻す
const scores = new Map([
  ["alice", 85], ["bob", 42], ["carol", 78], ["dave", 30]
]);

const passed = new Map(
  [...scores].filter(([_, s]) => s >= 60)
);
console.log([...passed.keys()]); // ["alice", "carol"]

// values だけ集計
const total = [...scores.values()].reduce((a, b) => a + b, 0);
console.log(total); // 235

// keys を昇順ソートして新しい Map に
const sorted = new Map(
  [...scores].sort(([a], [b]) => a.localeCompare(b))
);

3.4 clear() で一括削除

個別に delete するのではなく clear() を使えば全エントリを一括で消せます。Map インスタンス自体は再利用できるので、長期間生きるキャッシュをリセットする用途に向きます。

// clear で全削除
const cache = new Map();
cache.set("k1", "v1");
cache.set("k2", "v2");
console.log(cache.size); // 2
cache.clear();
console.log(cache.size); // 0
console.log(cache instanceof Map); // true(再利用可能)

4. Map ⇔ Object / JSON との相互変換

Map は JSON.stringifyそのままシリアライズできません(空 {} になる)。実務では Object との相互変換と、独自 replacer / reviver の用意がほぼ必須です。

4.1 Object → Map

もっとも素直なのは Object.entries 経由です。ネストオブジェクトは展開されない(浅い変換)点に注意してください。

// Object → Map(浅い変換)
const obj = { host: "localhost", port: 8080 };
const m = new Map(Object.entries(obj));
console.log(m.get("port")); // 8080

// 深い変換が必要な場合は再帰的に行う
function toMapDeep(value) {
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
    return value;
  }
  const m = new Map();
  for (const [k, v] of Object.entries(value)) {
    m.set(k, toMapDeep(v));
  }
  return m;
}

const deep = toMapDeep({ a: { b: { c: 1 } } });
console.log(deep.get("a").get("b").get("c")); // 1

4.2 Map → Object

逆方向は Object.fromEntries が便利です。ただし非文字列キーは文字列化されるため、純粋なキャッシュ用途では往復で情報が失われることを意識してください。

// Map → Object
const m = new Map([["a", 1], ["b", 2]]);
const obj = Object.fromEntries(m);
console.log(obj); // { a: 1, b: 2 }

// 数値キーは文字列化される
const numKeyMap = new Map([[1, "one"], [2, "two"]]);
const numKeyObj = Object.fromEntries(numKeyMap);
console.log(Object.keys(numKeyObj)); // ["1", "2"] ← 文字列に

4.3 Map を JSON.stringify する

Map をそのまま渡すと空オブジェクトになります。replacer / reviver を自作することで、Map らしさを残したまま JSON 化できます。

// Map を JSON 経由で送受信するパターン
function replacer(key, value) {
  if (value instanceof Map) {
    return { __type: "Map", entries: [...value.entries()] };
  }
  return value;
}

function reviver(key, value) {
  if (value && value.__type === "Map") {
    return new Map(value.entries);
  }
  return value;
}

const original = new Map([["a", 1], ["b", 2]]);
const json = JSON.stringify(original, replacer);
// '{"__type":"Map","entries":[["a",1],["b",2]]}'

const restored = JSON.parse(json, reviver);
console.log(restored instanceof Map); // true
console.log(restored.get("a"));        // 1

4.4 structuredClone で Map をディープコピー

2022 年標準化された structuredCloneMap / Set もそのまま深くコピーしてくれます。JSON 経由のコピーよりずっと正確で高速です。

// structuredClone は Map / Set もディープコピー可
const original = new Map([
  ["users", new Set(["alice", "bob"])],
  ["meta",  { createdAt: new Date() }]
]);

const copy = structuredClone(original);
copy.get("users").add("carol");

console.log([...original.get("users")]); // ["alice", "bob"] ← 不変
console.log([...copy.get("users")]);     // ["alice", "bob", "carol"]

5. Set の基本 API を完全攻略

Set は「値の集合」を扱うコレクションで、重複を自動で排除します。API は Map とほぼ対称で、add / has / delete / clear / size / forEach / keys / values / entries を覚えれば終わりです。

5.1 add / has / delete

add はチェーン可能、hasSameValueZero(NaN === NaN として扱う)で判定します。Array の includes と同じ比較規則なので、感覚的にはほとんど同じです。

// Set の基本操作
const tags = new Set();
tags.add("javascript").add("typescript").add("react");
tags.add("javascript"); // 重複は無視される

console.log(tags.size);          // 3
console.log(tags.has("react"));  // true
console.log(tags.has("vue"));    // false

tags.delete("react");
console.log([...tags]);          // ["javascript", "typescript"]

5.2 配列の重複排除イディオム

もっとも有名な活用例です。Set のコンストラクタに iterable を渡すと、重複が自動的に取り除かれます。1 行で書けるのがポイント。

// 配列の重複排除(古典イディオム)
const arr = [1, 2, 2, 3, 3, 3, 4];
const unique = [...new Set(arr)];
console.log(unique); // [1, 2, 3, 4]

// 文字列の重複文字を排除
const dedupChars = [...new Set("mississippi")].join("");
console.log(dedupChars); // "misp"

// オブジェクト配列の重複排除(キーで一意化)
const users = [
  { id: 1, name: "alice" },
  { id: 2, name: "bob" },
  { id: 1, name: "alice" }, // 重複
];
const uniqueById = [...new Map(users.map(u => [u.id, u])).values()];
console.log(uniqueById.length); // 2

5.3 イテレーション

Set も挿入順を保ちます。Map との対称性のため entries()[value, value] を返します(キー = 値)。

// Set のイテレーション
const s = new Set(["a", "b", "c"]);

for (const v of s) console.log(v);         // "a", "b", "c"
for (const v of s.values()) console.log(v); // "a", "b", "c"
for (const v of s.keys()) console.log(v);   // "a", "b", "c"(keys と values は同じ)
for (const e of s.entries()) console.log(e); // ["a","a"], ["b","b"], ["c","c"]

s.forEach((value, sameValue, set) => {
  console.log(value); // 第1,第2引数とも value
});

6. ES2025 Set 演算メソッド

2025 年に正式版へ進んだ Set の集合演算メソッド(union / intersection / difference / symmetricDifference / isSubsetOf / isSupersetOf / isDisjointFrom)で、これまで配列とループでゴリゴリ書いていた処理がワンライナーになります。

6.1 union(和集合)

2 つの集合のすべての要素を含む新しい Set を返します。元の Set は変更されません(イミュータブル)。

// union: 和集合
const a = new Set([1, 2, 3]);
const b = new Set([3, 4, 5]);

const u = a.union(b);
console.log([...u]); // [1, 2, 3, 4, 5]

// a / b は不変
console.log([...a]); // [1, 2, 3]
console.log([...b]); // [3, 4, 5]

6.2 intersection(積集合)

両方に含まれる要素だけを残します。「2 つのユーザータグの共通点を出す」「アクセス権の共通範囲を求める」といったロジックがほぼ 1 行で書けます。

// intersection: 積集合
const aliceTags = new Set(["js", "react", "css"]);
const bobTags   = new Set(["js", "vue", "css"]);

const common = aliceTags.intersection(bobTags);
console.log([...common]); // ["js", "css"]

// 「両者が持つ権限」のような典型ロジック
const adminPerms = new Set(["read", "write", "delete"]);
const userPerms  = new Set(["read", "write"]);
console.log([...adminPerms.intersection(userPerms)]);
// ["read", "write"]

6.3 difference / symmetricDifference

difference は「左にだけある要素」、symmetricDifference は「どちらか一方にしかない要素」を返します。前者は「未対応の差分検出」、後者は「変更があったキーの抽出」に使えます。

// difference: 差集合(A - B)
const required = new Set(["name", "email", "phone", "address"]);
const provided = new Set(["name", "email"]);

const missing = required.difference(provided);
console.log([...missing]); // ["phone", "address"] ← 未入力フィールド

// symmetricDifference: どちらか一方にだけある
const before = new Set([1, 2, 3]);
const after  = new Set([2, 3, 4]);
console.log([...before.symmetricDifference(after)]);
// [1, 4] ← 追加 or 削除されたもの

6.4 isSubsetOf / isSupersetOf / isDisjointFrom

3 つはすべて真偽値を返す判定メソッドです。RBAC(ロールベースアクセス制御)のチェックを宣言的に書けるようになります。

// 包含関係の判定
const required = new Set(["read", "write"]);
const userHas  = new Set(["read", "write", "admin"]);

console.log(required.isSubsetOf(userHas));   // true(必要権限 ⊆ 保有権限)
console.log(userHas.isSupersetOf(required)); // true

// 排他関係(交わりがない)
const allowed = new Set(["GET", "HEAD"]);
const denied  = new Set(["DELETE", "PUT"]);
console.log(allowed.isDisjointFrom(denied)); // true

6.5 Set 演算の引数は SetLike でよい

仕様上、引数はsize プロパティと has メソッド、keys() イテレータを持つオブジェクト」であれば受け取れます。Map もそのまま渡せる(キーを集合として扱う)のが地味に便利です。

// Set 演算は SetLike を受け付ける
const set = new Set([1, 2, 3]);
const map = new Map([[2, "a"], [3, "b"], [4, "c"]]);

// Map のキーを集合として扱える
const inter = set.intersection(map);
console.log([...inter]); // [2, 3]

// 自前 SetLike も可
const setLike = {
  size: 3,
  has: (v) => [10, 20, 30].includes(v),
  keys: function* () { yield 10; yield 20; yield 30; },
};
const u = new Set([10, 99]).union(setLike);
console.log([...u]); // [10, 99, 20, 30]

7. groupBy で集計を 1 行にする(ES2024)

ES2024 で導入された Object.groupBy / Map.groupBy は、配列を分類関数の戻り値ごとにまとめる静的メソッドです。これまで reduce で書いていたグルーピングを劇的に簡略化します。

7.1 Object.groupBy

戻り値がプレーンオブジェクトになるバージョンです。キーは文字列 / Symbol になります。

// Object.groupBy: 戻り値はプレーンオブジェクト
const users = [
  { name: "alice", role: "admin" },
  { name: "bob",   role: "user"  },
  { name: "carol", role: "admin" },
  { name: "dave",  role: "guest" },
];

const byRole = Object.groupBy(users, (u) => u.role);
console.log(byRole.admin); // [{name:"alice",...}, {name:"carol",...}]
console.log(byRole.guest); // [{name:"dave",...}]

7.2 Map.groupBy(任意キーで分類)

分類キーにオブジェクトを使いたい場合や、挿入順を厳密に保ちたい場合は Map.groupBy を使います。実務ではこちらの方が圧倒的に使い勝手が良いです。

// Map.groupBy: キーが任意の型
const logs = [
  { level: "info",  msg: "started"  },
  { level: "error", msg: "failed"   },
  { level: "info",  msg: "stopped"  },
  { level: "warn",  msg: "slow"     },
];

const grouped = Map.groupBy(logs, (l) => l.level);
console.log(grouped.get("info").length);  // 2
console.log(grouped.get("error").length); // 1

// オブジェクトをキーに使える(Object.groupBy ではできない芸当)
const buckets = { even: {}, odd: {} };
const nums = [1, 2, 3, 4, 5, 6];
const m = Map.groupBy(nums, (n) => n % 2 === 0 ? buckets.even : buckets.odd);
console.log(m.get(buckets.even)); // [2, 4, 6]
console.log(m.get(buckets.odd));  // [1, 3, 5]

7.3 groupBy で実用集計

もう一歩進めて、Map.groupBy の結果から件数や合計を即座に取り出すパターンを示します。reduce 比較で体感 3 分の 1の行数になります。

// groupBy → 件数集計
const orders = [
  { customer: "A", amount: 1000 },
  { customer: "B", amount: 2000 },
  { customer: "A", amount: 1500 },
  { customer: "C", amount:  500 },
];

const byCustomer = Map.groupBy(orders, (o) => o.customer);
const totals = new Map(
  [...byCustomer].map(([k, list]) => [k, list.reduce((s, o) => s + o.amount, 0)])
);
console.log(totals.get("A")); // 2500
console.log(totals.get("B")); // 2000

8. WeakMap / WeakSet / WeakRef でメモリリーク回避

「Map にキャッシュを溜め続けたらメモリが膨らんで OOM になった」――この典型的バグを防ぐのが Weak 系コレクションです。キーがどこからも参照されなくなった瞬間、エントリは GC によって自動削除されます。

8.1 WeakMap の基本

WeakMap のキーはオブジェクト限定で、列挙(keys / values / entries / forEach / size)がすべて使えません。これは「キーが GC されたかどうかが外部から観測できる」と仕様矛盾するためです。

// WeakMap の基本(列挙不可・size なし)
const meta = new WeakMap();

let node = { id: 1 };
meta.set(node, { createdAt: Date.now() });

console.log(meta.has(node));  // true
console.log(meta.get(node));  // { createdAt: ... }

node = null; // 参照を切る
// → meta 内のエントリも GC のタイミングで自動削除される
// (タイミングはエンジン任せ)

8.2 WeakMap でプライベートフィールド代替

ES2022 の #private 構文が来る前の定番テクニックですが、現在でもクラス外からプライベートデータを管理したい用途で重宝します。

// WeakMap でプライベートデータを外付け
const _private = new WeakMap();

class User {
  constructor(name, password) {
    _private.set(this, { password });
    this.name = name;
  }
  authenticate(p) {
    return _private.get(this).password === p;
  }
}

const u = new User("alice", "s3cret");
console.log(u.authenticate("s3cret"));  // true
console.log(u.password);                  // undefined(外から見えない)
// インスタンスが GC されると _private のエントリも自動消滅

8.3 WeakSet で「処理済みフラグ」を立てる

「あるノードを処理済みかどうか」だけを記録したい場面では WeakSet が最適です。Set と違い処理対象が消えれば自動で忘れるため、長時間動くプロセスでもメモリが安定します。

// WeakSet で処理済みオブジェクトを記録
const processed = new WeakSet();

function process(item) {
  if (processed.has(item)) return; // すでに処理済み
  // ... 重い処理 ...
  processed.add(item);
}

let items = [{ id: 1 }, { id: 2 }, { id: 3 }];
items.forEach(process);
items.forEach(process); // 2 回目は何もしない

items = null; // ← processed の中身も自動的に消える

8.4 WeakRef + FinalizationRegistry で高度なキャッシュ

WeakRefオブジェクトへの弱参照を保持しつつ、まだ生きていれば取り出せる仕組みです。FinalizationRegistry と組み合わせると、GC されたタイミングでクリーンアップを走らせられます。

// WeakRef + FinalizationRegistry
const cache = new Map();
const registry = new FinalizationRegistry((key) => {
  cache.delete(key);
  console.log(`GC'd entry: ${key}`);
});

function cacheSet(key, obj) {
  cache.set(key, new WeakRef(obj));
  registry.register(obj, key);
}

function cacheGet(key) {
  const ref = cache.get(key);
  if (!ref) return undefined;
  const obj = ref.deref();
  if (!obj) { cache.delete(key); return undefined; } // 既に GC 済み
  return obj;
}

// 利用側
let big = { payload: new Array(1_000_000).fill(0) };
cacheSet("k", big);
console.log(cacheGet("k")?.payload.length); // 1000000
big = null; // 強参照が切れる → いずれ GC される

9. 実装パターン集

ここからは Map / Set / WeakMap を使った実戦的なデータ構造とユーティリティを一気に提示します。コピペしてそのまま動くものばかりです。

9.1 LRU キャッシュ

Map の挿入順保証を利用すると、わずか 20 行で LRU(Least Recently Used)キャッシュが実装できます。get 時に一度 delete → set する小技で「最近使った」を表現します。

// 最小実装 LRU
class LRUCache {
  constructor(capacity) {
    this.capacity = capacity;
    this.cache = new Map();
  }
  get(key) {
    if (!this.cache.has(key)) return undefined;
    const v = this.cache.get(key);
    this.cache.delete(key);
    this.cache.set(key, v); // 末尾に移動 = 最新
    return v;
  }
  set(key, value) {
    if (this.cache.has(key)) this.cache.delete(key);
    this.cache.set(key, value);
    if (this.cache.size > this.capacity) {
      const oldest = this.cache.keys().next().value;
      this.cache.delete(oldest);
    }
  }
}

const lru = new LRUCache(3);
lru.set("a", 1); lru.set("b", 2); lru.set("c", 3);
lru.get("a");                  // a を最新に
lru.set("d", 4);               // b が押し出される
console.log([...lru.cache.keys()]); // ["c", "a", "d"]

9.2 隣接リスト形式のグラフ

頂点をキー、隣接頂点の Set を値にすると、シンプルな無向グラフが書けます。BFS / DFS の土台として優秀です。

// Map + Set で隣接リスト
class Graph {
  constructor() { this.adj = new Map(); }
  addNode(v) {
    if (!this.adj.has(v)) this.adj.set(v, new Set());
  }
  addEdge(u, v) {
    this.addNode(u); this.addNode(v);
    this.adj.get(u).add(v);
    this.adj.get(v).add(u);
  }
  neighbors(v) { return this.adj.get(v) ?? new Set(); }
  bfs(start) {
    const visited = new Set([start]);
    const queue = [start], order = [];
    while (queue.length) {
      const v = queue.shift();
      order.push(v);
      for (const n of this.neighbors(v)) {
        if (!visited.has(n)) { visited.add(n); queue.push(n); }
      }
    }
    return order;
  }
}

const g = new Graph();
g.addEdge("A","B"); g.addEdge("A","C"); g.addEdge("B","D");
console.log(g.bfs("A")); // ["A","B","C","D"]

9.3 多値マップ(MultiMap)

「1 キーに複数値」をきれいに扱う MultiMap も Map + Set / Array の組み合わせで作れます。タグ付きデータの管理に最適です。

// 1 キーに複数値を持たせる
class MultiMap {
  #map = new Map();
  add(key, value) {
    if (!this.#map.has(key)) this.#map.set(key, new Set());
    this.#map.get(key).add(value);
    return this;
  }
  get(key) { return this.#map.get(key) ?? new Set(); }
  delete(key, value) {
    const set = this.#map.get(key);
    if (!set) return false;
    const ok = set.delete(value);
    if (set.size === 0) this.#map.delete(key);
    return ok;
  }
  *entries() {
    for (const [k, set] of this.#map) for (const v of set) yield [k, v];
  }
}

const mm = new MultiMap();
mm.add("fruit", "apple").add("fruit", "banana").add("veg", "carrot");
console.log([...mm.get("fruit")]); // ["apple", "banana"]

9.4 メモ化(memoize)

Map で引数 → 戻り値を覚えておけば、重い純粋関数を高速化できます。引数がオブジェクトの場合は WeakMap を使うとメモリリークを避けられます。

// 引数 1 つのメモ化(プリミティブキー)
function memoize(fn) {
  const cache = new Map();
  return (arg) => {
    if (cache.has(arg)) return cache.get(arg);
    const v = fn(arg);
    cache.set(arg, v);
    return v;
  };
}

const fib = memoize(function f(n) {
  return n < 2 ? n : f(n - 1) + f(n - 2);
});
console.log(fib(40)); // 102334155(瞬時)

// 引数がオブジェクトのメモ化(WeakMap で GC 連動)
function memoizeObj(fn) {
  const cache = new WeakMap();
  return (obj) => {
    if (cache.has(obj)) return cache.get(obj);
    const v = fn(obj);
    cache.set(obj, v);
    return v;
  };
}

9.5 デバウンス(コンポーネントごと)

WeakMap を使うと「インスタンスごとに別々のタイマーを持つ debounce」が書けます。React / Vue のコンポーネントごとに状態を分離したい用途で便利です。

// インスタンスごとに状態を持つ debounce
const timers = new WeakMap();

function debounceFor(instance, fn, ms) {
  clearTimeout(timers.get(instance));
  timers.set(instance, setTimeout(fn, ms));
}

// 使い方
class SearchBox {
  onInput(value) {
    debounceFor(this, () => this.search(value), 300);
  }
  search(v) { console.log("searching:", v); }
}

9.6 型安全な EventEmitter

Map でイベント名 → リスナー Set を持つだけで、軽量な EventEmitter が書けます。TypeScript と組み合わせて型安全にすると Node.js の EventEmitter 以上の体験になります。

// 型安全 EventEmitter(TypeScript)
type Listener<T> = (payload: T) => void;

class TypedEmitter<Events extends Record<string, any>> {
  #map = new Map<keyof Events, Set<Listener<any>>>();
  on<K extends keyof Events>(event: K, fn: Listener<Events[K]>) {
    if (!this.#map.has(event)) this.#map.set(event, new Set());
    this.#map.get(event)!.add(fn);
    return () => this.#map.get(event)!.delete(fn); // unsubscribe
  }
  emit<K extends keyof Events>(event: K, payload: Events[K]) {
    this.#map.get(event)?.forEach(fn => fn(payload));
  }
}

// 使い方
type Events = { login: { userId: number }; logout: void };
const ee = new TypedEmitter<Events>();
const off = ee.on("login", (p) => console.log(p.userId)); // p: {userId:number}
ee.emit("login", { userId: 1 });
off();

9.7 簡易ハッシュテーブルの自前実装

「Map の中身を理解したい」「面接対策で書いてみたい」場面のために、配列ベースのハッシュテーブルを示します(衝突はチェイン法)。

// チェイン法のハッシュテーブル
class HashTable {
  constructor(size = 64) {
    this.buckets = Array.from({ length: size }, () => []);
  }
  #hash(key) {
    const s = String(key);
    let h = 0;
    for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
    return Math.abs(h) % this.buckets.length;
  }
  set(key, value) {
    const b = this.buckets[this.#hash(key)];
    const e = b.find(([k]) => k === key);
    if (e) e[1] = value; else b.push([key, value]);
  }
  get(key) {
    return this.buckets[this.#hash(key)].find(([k]) => k === key)?.[1];
  }
}

const ht = new HashTable();
ht.set("apple", 1); ht.set("banana", 2);
console.log(ht.get("apple")); // 1

10. パフォーマンスと使い分けの最終判断

「いつ Map を使い、いつ Object を使うか」――最後に実測ベンチと判断基準を整理します。感覚で決めず、ユースケースで決めるのが鉄則です。

10.1 Map vs Object: ベンチマーク

10 万件の挿入 / 取得を比較するスニペットです。エンジンや件数で結果は変動しますが、件数が多い・キーが動的・delete が頻発する用途では Map が圧倒します。

// Map vs Object ベンチ(Node.js で実行可)
const N = 100_000;

console.time("Object set");
const o = {};
for (let i = 0; i < N; i++) o["k" + i] = i;
console.timeEnd("Object set");

console.time("Map set");
const m = new Map();
for (let i = 0; i < N; i++) m.set("k" + i, i);
console.timeEnd("Map set");

console.time("Object get");
for (let i = 0; i < N; i++) o["k" + i];
console.timeEnd("Object get");

console.time("Map get");
for (let i = 0; i < N; i++) m.get("k" + i);
console.timeEnd("Map get");

10.2 Set vs Array.includes

「N 件の配列に対し M 回の存在判定」だと、Array の includesO(N×M)、Set の hasO(M)です。M / N が大きいほど差が劇的に開きます。

// Set vs Array.includes
const N = 10_000, M = 10_000;
const arr = Array.from({ length: N }, (_, i) => i);
const set = new Set(arr);
const probes = Array.from({ length: M }, () => Math.floor(Math.random() * N));

console.time("Array.includes");
let c1 = 0;
for (const p of probes) if (arr.includes(p)) c1++;
console.timeEnd("Array.includes");

console.time("Set.has");
let c2 = 0;
for (const p of probes) if (set.has(p)) c2++;
console.timeEnd("Set.has");

console.log(c1 === c2); // true(結果は同じ、速度だけ違う)

10.3 使い分けチートシート

覚えるべき判断基準を一覧化します。「キーが文字列で固定、JSON でやり取り、構造が決まっている」なら Object「動的・任意型キー・件数が多い・順序が大事」なら Map「重複排除・存在判定が多い」なら Setです。

// チートシート
// 用途                                  ベスト選択
// --------------------------------------------------
// JSON で送受信される設定値              Object
// 型が固定された DTO                      Object / class
// キーが動的に増減する辞書                Map
// キーがオブジェクト / 関数               Map / WeakMap
// 大量の重複排除                          Set
// 高速な存在判定                          Set
// 集合演算(union/intersection)           Set
// オブジェクトにメタを紐付け(GC 連動)    WeakMap
// "処理済み" フラグ(GC 連動)            WeakSet
// 1 キーに複数値                          Map + Set
// LRU / TTL キャッシュ                    Map(挿入順)
// グラフ・隣接リスト                      Map + Set

10.4 TypeScript で型を厳格化する

TypeScript の Map<K, V> / Set<T> / WeakMap<K extends WeakKey, V> / ReadonlyMap / ReadonlySet を組み合わせれば、コレクションの利用面まで型で固められます。

// TS で Map/Set/WeakMap を型安全に
const userById: Map<number, { name: string; email: string }> = new Map();
userById.set(1, { name: "alice", email: "a@example.com" });
// userById.set("2", ...); // ❌ TS Error: 数値以外不可

const tagSet: Set<"new" | "sale" | "hot"> = new Set();
tagSet.add("new"); // ✅
// tagSet.add("foo"); // ❌ TS Error

// 読取専用ビューを公開する典型パターン
class UserStore {
  #map = new Map<number, string>();
  get users(): ReadonlyMap<number, string> { return this.#map; }
  add(id: number, name: string) { this.#map.set(id, name); }
}
const s = new UserStore();
s.add(1, "alice");
// s.users.set(2, "bob"); // ❌ ReadonlyMap には set がない

10.5 学習リソースとキャリアパス

Map / Set / WeakMap を使いこなせるレベルになると、データ構造とアルゴリズム、メモリ管理、TypeScript の型システム、設計パターンといった中級〜上級トピックが一気に視野に入ります。独学で詰まりやすい部分でもあるので、現役エンジニアからレビューを受けられる環境で一気に伸ばすのが効率的です。テックアカデミー・侍エンジニア・DMM WEBCAMP・レバテックカレッジなどのスクール / エージェント比較もぜひ参考にしてみてください。

10.6 まとめ

Map / Set / WeakMap / WeakSet は「Object / Array でも書けるけど、明らかに専用コレクションを使ったほうが速く・安全に・読みやすく書ける」領域です。本記事で押さえたポイントを最後に並べておきます。

// 本記事の要点まとめ
// 1. キーが文字列以外(数値・オブジェクト・関数)になるなら Map 一択
// 2. 件数が増えるキャッシュ用途では Object より Map が高速 & 安全
// 3. 配列からの重複排除は new Set(arr) → [...set] で 1 行
// 4. ES2025 で union/intersection/difference/symmetricDifference が標準化
//    isSubsetOf/isSupersetOf/isDisjointFrom も標準化
// 5. ES2024 の Object.groupBy / Map.groupBy で集計が劇的に短く書ける
// 6. キャッシュ・メタ情報・処理済みフラグは WeakMap / WeakSet で GC 連動
// 7. WeakRef + FinalizationRegistry で高度なメモリ管理が可能
// 8. LRU / グラフ / MultiMap / メモ化 / 型安全 EventEmitter は
//    Map + Set の組み合わせで 20〜30 行で実装できる
// 9. TS の ReadonlyMap / ReadonlySet で「読み取り専用ビュー」を表現
// 10. 迷ったら Object より Map、Array.includes より Set.has を選ぶ

Map / Set を「なんとなく知っている」から「適材適所で使い分けられる」段階に進むだけで、アプリケーションのパフォーマンスもコードの可読性も大きく向上します。ぜひ本記事のコードスニペットを手元で動かし、自分のプロジェクトに 1 つずつ取り入れていってください。

コメント

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