JavaScript の Map と Set を TypeScript で使うと、「キーは文字列、値は数値」といった中身の型まで型で表現できます。ところが new Map() とだけ書いて使い始めると、いつのまにか型チェックがまったく効かない状態になっていた、ということが起こります。この記事では Map<K, V> と Set<T> の型引数の書き方、初期値からどう型が推論されるか、get() の戻り値が V | undefined になる理由と扱い方、ReadonlyMap や WeakMap の型、そして Record(ただのオブジェクト)との使い分けまでを、実際の TypeScript の挙動に沿って整理します。
目次
Map<K, V> と Set<T> は型引数で中身を決める
Map と Set はジェネリック型として定義されています。ジェネリクスとは、型そのものを引数のように外から渡せる仕組みのことです。Map はキーの型 K と値の型 V の2つ、Set は要素の型 T を1つ受け取ります。new のうしろに <> で型を書けば、その Map や Set に入れられる値が固定されます。
// キーが string、値が number の Map
const scores = new Map<string, number>();
scores.set('alice', 80);
scores.set('bob', 92);
// error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
scores.set('carol', '75');
// error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
scores.set(1, 100);
// 要素が string の Set
const tags = new Set<string>();
tags.add('typescript');
// error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
tags.add(1);
型引数はプリミティブ型だけでなく、ユニオン型やインターフェースなど好きな型を指定できます。キーにユニオン型を指定すると、そこに無い値を書いた時点でエラーになるので、キーの打ち間違いをコンパイル時に潰せます。
type Status = 'draft' | 'published';
interface User {
id: number;
name: string;
}
// キーを Status に限定した Map
const labels = new Map<Status, string>([
['draft', '下書き'],
['published', '公開済み'],
]);
// error TS2345: Argument of type '"archived"' is not assignable to parameter of type 'Status'.
labels.set('archived', 'アーカイブ');
// 値にオブジェクトの型を指定することもできる
const users = new Map<number, User>();
users.set(1, { id: 1, name: 'Alice' });
new Map() だけだと Map<any, any> になる
ここが最初のつまずきどころです。型引数も初期値も書かずに new Map() とだけ書くと、TypeScript はキーも値も any と推論します。標準ライブラリの MapConstructor に new (): Map<any, any>; という引数なしのオーバーロードが定義されているためです。any は「型チェックを放棄する型」なので、何を入れても何を取り出しても一切怒られません。
const m = new Map(); // m: Map<any, any>
m.set('a', 1);
m.set(1, { x: 1 }); // キーも値もバラバラだがエラーにならない
const v = m.get('a'); // v: any
v.toFixed(2); // 型としては通る。中身が数値でなければ実行時エラー
やっかいなのは、strict(noImplicitAny)を有効にしていても、このコードが1つもエラーにならない点です。noImplicitAny が検出するのは「型注釈が無くて暗黙に any になった変数や引数」であって、ここでは型定義側が明示的に any を返しているため対象外になります。つまりコンパイラに任せていても気づけないので、空の Map を作るときは自分で型引数を書く必要があります。
一方で new Set() は Set<unknown> になります。unknown は「何でも入るが、取り出したあと何もできない型」なので、追加はできても使おうとした時点でエラーになり、型引数の書き忘れに気づけます。同じ書き忘れでも Map のほうが静かに壊れる、と覚えておくとよいでしょう。
const s = new Set(); // s: Set<unknown>
s.add('a');
s.add(1); // unknown なので追加は通る
for (const x of s) {
// error TS18046: 'x' is of type 'unknown'.
console.log(x.toUpperCase());
}
初期値を渡したときに推論される型
new Map() や new Set() にコンストラクタ引数を渡すと、そこから型引数が推論されます。Map は [キー, 値] のタプルの配列、Set は値の配列を受け取ります。推論結果は渡し方によって次のように変わります。
| 書き方 | 推論される型 |
|---|---|
new Map() | Map<any, any> |
new Map([]) | Map<unknown, unknown> |
new Map([['a', 1]]) | Map<string, number> |
new Map([['a', 1]] as const) | Map<'a', 1> |
new Set() | Set<unknown> |
new Set([]) | Set<never> |
new Set(['a', 'b']) | Set<string> |
new Set(['a', 'b'] as const) | Set<'a' | 'b'> |
注目したいのは空配列を渡したときです。new Set([]) は要素の型を推論する材料が無いため Set<never>、つまり何も追加できない Set になります。「あとから add するから」と空配列を渡すのは避け、型引数を明示してください。
// 初期値から Map<string, number> と推論される
const scores = new Map([
['alice', 80],
['bob', 92],
]);
// 空配列だと Set<never> になり、何も追加できなくなる
const empty = new Set([]);
// error TS2345: Argument of type '"a"' is not assignable to parameter of type 'never'.
empty.add('a');
// 型引数を書けば意図どおり
const tags = new Set<string>();
値の型が混ざっているとエラーになる
初期値の中で値の型が揃っていないと、TypeScript はユニオン型に広げてはくれず、オーバーロードの解決に失敗してエラーになります。値の型が混ざる Map を作りたいなら、型引数のほうでユニオン型を明示します。
// error TS2769: No overload matches this call.
// Overload 2 of 4, '(entries?: readonly (readonly [string, number])[] | null | undefined):
// Map<string, number>', gave the following error.
// Type 'string' is not assignable to type 'number'.
const bad = new Map([
['retry', 3],
['mode', 'fast'],
]);
// 型引数でユニオン型を指定すれば通る
const config = new Map<string, number | string>([
['retry', 3],
['mode', 'fast'],
]);
as const でリテラル型を保つ
通常の推論では 'draft' のような文字列は string に広げられます。キーを具体的な文字列のまま保ちたいときは、初期値の配列に as const を付けます。読み取り専用のタプルとして扱われるようになり、リテラル型がそのまま型引数に入ります。
// labels: Map<string, string>
const labels = new Map([
['draft', '下書き'],
['published', '公開済み'],
]);
// labelsConst: Map<'draft' | 'published', '下書き' | '公開済み'>
const labelsConst = new Map([
['draft', '下書き'],
['published', '公開済み'],
] as const);
// キーが限定されるので、知らないキーを渡すとエラーになる
// error TS2345: Argument of type '"archived"' is not assignable to
// parameter of type '"draft" | "published"'.
labelsConst.get('archived');
ただし as const で得られるのはキーとバリューそれぞれのユニオン型であって、「このキーにはこの値」という対応関係までは型に残りません。上の例なら labelsConst.get('draft') の型は '下書き' | '公開済み' | undefined です。キーごとに値の型を変えたいなら、次に触れる Record やオブジェクトリテラルのほうが向いています。
get() が V | undefined を返す理由
Map<string, number> の get() は number ではなく number | undefined を返します。Map はどんなキーで問い合わせられても文句を言わない入れ物で、そのキーが存在するかどうかはコンパイル時には分からないからです。存在しないキーを渡せば実行時に undefined が返るので、型もそれを正直に表しています。
ここで has() を使って存在確認しても、get() の戻り値の型は絞り込まれません。has() は単に boolean を返すメソッドで、型を絞り込む型述語(Type Predicate)ではないためです。
const scores = new Map<string, number>([['alice', 80]]);
const alice = scores.get('alice'); // alice: number | undefined
// error TS18048: 'alice' is possibly 'undefined'.
console.log(alice.toFixed(0));
if (scores.has('alice')) {
// has() では絞り込まれない
// error TS2322: Type 'number | undefined' is not assignable to type 'number'.
const n: number = scores.get('alice');
}
扱い方は3通りあります。取得した値を変数に受けて undefined でないか確かめる、既定値を ?? で与える、そして「無いはずがない」場面では例外を投げる小さなヘルパーを用意する方法です。とくに3つ目は、Map を多用するコードで undefined チェックが散らばるのを防げます。
const scores = new Map<string, number>([['alice', 80], ['bob', 92]]);
// 1. 変数に受けて絞り込む
const alice = scores.get('alice');
if (alice !== undefined) {
console.log(alice.toFixed(0)); // ここでは number
}
// 2. 既定値を与える(?? なら 0 を潰さない)
const bob: number = scores.get('bob') ?? 0;
// 3. 必ずあるはずのキー用のヘルパー
function getOrThrow<K, V>(map: ReadonlyMap<K, V>, key: K): V {
const value = map.get(key);
if (value === undefined) {
throw new Error(`key not found: ${String(key)}`);
}
return value;
}
const carol: number = getOrThrow(scores, 'alice');
ひとつだけ注意点があります。値の型自体に undefined が含まれる Map<string, number | undefined> のような場合、get() が返す undefined が「キーが無い」のか「値として undefined が入っている」のか区別できません。この2つを区別する必要があるなら、has() で存在を確かめてから get() するか、そもそも値に undefined を入れない設計にしてください。上の getOrThrow も、値に undefined を持たない Map であることが前提です。
ループと配列化での型
Map を for...of で回すと [キー, 値] のタプルが取り出せます。分割代入で受ければ、それぞれ K と V の型が付くので、追加の型注釈は要りません。keys() / values() / entries() は TypeScript 5.6 以降では MapIterator<T>(Set なら SetIterator<T>)という型を返しますが、これは IterableIterator を拡張したものなので、使い方はこれまでと変わりません。
const scores = new Map<string, number>([['alice', 80], ['bob', 92]]);
// name: string, score: number として推論される
for (const [name, score] of scores) {
console.log(`${name}: ${score.toFixed(0)}`);
}
for (const name of scores.keys()) { /* name: string */ }
for (const score of scores.values()) { /* score: number */ }
// entries(): MapIterator<[string, number]>
for (const [name, score] of scores.entries()) { /* 上の for...of と同じ */ }
// forEach は (値, キー, map) の順。分割代入ではないので順番に注意
scores.forEach((score, name) => {
console.log(name, score);
});
Map や Set には map() や filter() がありません。並べ替えたり加工したりしたいときは、いったん配列にします。スプレッド構文と Array.from() のどちらでも型は正しく付き、Map を配列化すると [K, V][]、Set なら T[] になります。
const scores = new Map<string, number>([['alice', 80], ['bob', 92]]);
const pairs = [...scores]; // [string, number][]
const same = Array.from(scores); // [string, number][]
const names = [...scores.keys()]; // string[]
// 点数の高い順に並べ替える
const ranking = [...scores]
.sort((a, b) => b[1] - a[1])
.map(([name, score]) => `${name}(${score})`); // string[]
// Array.from は第2引数で変換もできる
const labels = Array.from(scores, ([name, score]) => `${name}:${score}`); // string[]
// 配列の重複排除は Set 経由が定番
const unique = [...new Set(['a', 'a', 'b'])]; // string[]
Map が見つからない・for…of でエラーになるとき
Map と Set は ES2015(ES6)で追加された機能なので、tsconfig.json の target と lib の設定が古いとコンパイルエラーになります。エラーメッセージが2種類あり、原因も対処も違うので分けて説明します。
Cannot find name ‘Map’. と言われる(lib の問題)
lib は「どの標準ライブラリの型定義を読み込むか」の設定です。これが es5 のままだと Map や Set の型定義自体が存在せず、TS2583 のエラーになります。lib を省略している場合は target に応じた既定値が使われるので、target を es2015 以上にするだけでも解決します。
error TS2583: Cannot find name 'Map'. Do you need to change your target library?
Try changing the 'lib' compiler option to 'es2015' or later.
can only be iterated through … と言われる(target の問題)
型定義はあるのに for...of やスプレッド構文で TS2802 が出る場合は、target が es5 になっているのが原因です。ES5 にはイテレータの構文が無いため、TypeScript は Map のような「配列ではない反復可能オブジェクト」を安全に展開できません。target を es2015 以上に上げるか、どうしても ES5 で出力する必要があるなら downlevelIteration を有効にします(ヘルパーコードが出力に追加され、その分だけ生成される JavaScript は大きくなります)。
error TS2802: Type 'Map<string, number>' can only be iterated through when using
the '--downlevelIteration' flag or with a '--target' of 'es2015' or higher.
{
"compilerOptions": {
"strict": true,
"target": "es2020",
"lib": ["es2020", "dom"]
}
}
なお Array.from(map) は target: es5 のままでも TS2802 になりません。構文ではなく関数呼び出しだからです(Array.from 自体が ES2015 の機能なので lib は必要です)。設定を変えられない既存プロジェクトでは、スプレッドの代わりに Array.from() を使うと回避できます。
変更させたくないときの ReadonlyMap / ReadonlySet
ReadonlyMap<K, V> と ReadonlySet<T> は、読み取り系のメンバーだけを持つ型です。get / has / size / forEach やイテレーションは使えますが、set / add / delete / clear は型として存在しません。Map<K, V> は ReadonlyMap<K, V> に代入できるので、「受け取るだけで書き換えない」関数の引数に付けておくと意図が伝わります。
function total(scores: ReadonlyMap<string, number>): number {
let sum = 0;
for (const score of scores.values()) {
sum += score;
}
// error TS2339: Property 'set' does not exist on type 'ReadonlyMap<string, number>'.
scores.set('x', 0);
return sum;
}
const scores = new Map<string, number>([['alice', 80]]);
total(scores); // Map はそのまま渡せる
これはあくまで型レベルの制約で、実行時に凍結されるわけではありません。as で型を戻したり、同じ Map を持つ別のコードから書き換えたりすれば変更できてしまいます。それでも、意図しない変更をレビュー前にコンパイラが止めてくれる価値は十分あります。
ReadonlySet にはもうひとつ便利な使い道があります。Set<T> の has() は引数の型が T なので、Set<Status> に対して素の string を渡すとエラーになります。ここで ReadonlySet<string> として扱えば、値の検証用の型ガード(型述語を返す関数)が素直に書けます。
type Status = 'draft' | 'published';
const STATUSES = new Set<Status>(['draft', 'published']);
declare const input: string;
// error TS2345: Argument of type 'string' is not assignable to parameter of type 'Status'.
STATUSES.has(input);
// ReadonlySet<string> として見れば任意の文字列を問い合わせられる
function isStatus(value: string): value is Status {
return (STATUSES as ReadonlySet<string>).has(value);
}
if (isStatus(input)) {
const status: Status = input; // ここでは Status に絞り込まれている
}
Map と Record(オブジェクト)の使い分け
「キーと値の組」を扱うなら、Record<K, V> 型のオブジェクトでも同じことができます。どちらを選ぶかは、キーが決まっているかどうかで考えると整理しやすくなります。
| 観点 | Map<K, V> | Record<K, V>(オブジェクト) |
|---|---|---|
| キーの型 | 何でも可(オブジェクトや数値もそのまま) | 文字列・数値・シンボルのみ |
| キーの網羅チェック | できない | キーがユニオン型なら、書き漏らしがエラーになる |
| キーごとに値の型を変える | できない(全体で1つの V) | できる(interface や個別指定) |
| 要素数 | size | Object.keys(obj).length |
| キーの順序 | 挿入順が保たれる | 整数風のキーが先に並ぶなど独自の規則がある |
| JSON 化 | JSON.stringify() で {} になる | そのまま出力される |
| 頻繁な追加・削除 | 得意(delete 演算子が不要) | 可能だが delete が必要 |
キーの顔ぶれがコンパイル時に決まっているなら Record、実行時に増減する動的なデータなら Map、というのが基本的な指針です。ステータスごとのラベルのように「全パターンを必ず書いてほしい」ものは Record にしておくと、キーを1つ増やしたときに書き漏らしがエラーになります。逆にユーザー ID をキーにしたキャッシュのような用途は Map の出番です。
type Status = 'draft' | 'published';
// キーが決まっている → Record(書き漏らすとエラーになる)
// error TS2741: Property 'published' is missing in type '{ draft: string; }'
// but required in type 'Record<Status, string>'.
const labels: Record<Status, string> = {
draft: '下書き',
};
// 実行時に増減する → Map
const cache = new Map<number, { name: string }>();
cache.set(1, { name: 'Alice' });
cache.delete(1);
両者を変換するときは Object.entries() と Object.fromEntries() を使いますが、型が少し落ちる点に注意してください。Object.entries() の戻り値はキーが string に広がるため、Record<Status, string> から作った Map は Map<Status, string> ではなく Map<string, string> になります。キーの型を保ちたければ型引数を明示するのが確実です。
const labels: Record<Status, string> = { draft: '下書き', published: '公開済み' };
// Object.entries() は [string, string][] を返すので Map<string, string> になる
const loose = new Map(Object.entries(labels));
// キーの型を保ちたいときは明示する
const strict = new Map<Status, string>(
Object.entries(labels) as [Status, string][],
);
// Map → オブジェクト(キーが string の Map のみ)
const obj = Object.fromEntries(strict); // { [k: string]: string }
// Map をそのまま JSON にすると中身が消える
console.log(JSON.stringify(strict)); // {}
console.log(JSON.stringify(Object.fromEntries(strict))); // {"draft":"下書き",...}
WeakMap / WeakSet の型
WeakMap<K, V> と WeakSet<T> は、キーへの参照を弱く保持するコレクションです。キーとして使ったオブジェクトがどこからも参照されなくなれば、エントリごとガベージコレクションの対象になります。DOM 要素やインスタンスに付随情報を紐づけたいときに、メモリリークを避けながら使えます。
型の面での最大の違いは、キーがオブジェクトに限られることです。型定義では K extends WeakKey と制約されており、WeakKey は既定ではオブジェクト型を指します(lib に esnext などを含めると、シンボルをキーにできる ES2023 の仕様が反映されて symbol も含まれるようになります)。文字列や数値を渡すとコンパイルエラーです。
interface Session {
id: string;
}
// キーが Session、値が { visits: number }
const meta = new WeakMap<Session, { visits: number }>();
const session: Session = { id: 'abc' };
meta.set(session, { visits: 1 });
const visits = meta.get(session)?.visits; // number | undefined
// キーはオブジェクトなので、文字列を渡すとエラーになる
// error TS2345: Argument of type 'string' is not assignable to parameter of type 'Session'.
meta.set('abc', { visits: 1 });
// そもそもキーの型引数にプリミティブ型を指定できない
// error TS2344: Type 'string' does not satisfy the constraint 'object'.
const ng = new WeakMap<string, number>();
// 既に見たオブジェクトを記録する用途
const seen = new WeakSet<Session>();
seen.add(session);
console.log(seen.has(session)); // true
もうひとつ知っておきたいのは、WeakMap と WeakSet には size も keys() も forEach() も無く、for...of で回せないことです(いつ回収されるか分からないため、列挙できると挙動が不安定になるからです)。使えるのは WeakMap なら get / set / has / delete、WeakSet なら add / has / delete だけです。中身を数えたり列挙したりする必要があるなら、通常の Map / Set を選んでください。なお、型引数を省いた new WeakMap() は値の型が any になるので、こちらも明示するのが安全です。
まとめ
Map と Set はジェネリック型なので、new Map<string, number>() のように型引数を書くことで中身の型を固定できます。型引数も初期値も書かない new Map() は Map<any, any> になり、strict でも警告されないまま型チェックが無効化されるため、空のコレクションを作るときは必ず型引数を書いてください(new Set() は Set<unknown>、new Set([]) は Set<never> になります)。初期値からの推論では文字列が string に広がるので、リテラル型を保ちたいときは as const を添えます。get() が V | undefined を返すのは存在しないキーを問い合わせられるからで、has() では絞り込まれない点に注意し、変数に受けて比較するか ?? で既定値を与えるのが基本です。読み取り専用にしたい引数には ReadonlyMap / ReadonlySet、オブジェクトに情報を紐づけたいときは WeakMap / WeakSet を使い分け、キーが決まりきっているデータは Record にする、と考えれば選択に迷いません。