1. ホーム
  2. TypeScript

【TypeScript】Map・Set に型を付ける方法|ジェネリクスでキー・値の型を指定する

Share

JavaScript の MapSet を TypeScript で使うと、「キーは文字列、値は数値」といった中身の型まで型で表現できます。ところが new Map() とだけ書いて使い始めると、いつのまにか型チェックがまったく効かない状態になっていた、ということが起こります。この記事では Map<K, V>Set<T> の型引数の書き方、初期値からどう型が推論されるか、get() の戻り値が V | undefined になる理由と扱い方、ReadonlyMapWeakMap の型、そして Record(ただのオブジェクト)との使い分けまでを、実際の TypeScript の挙動に沿って整理します。

Map<K, V> と Set<T> は型引数で中身を決める

MapSetジェネリック型として定義されています。ジェネリクスとは、型そのものを引数のように外から渡せる仕組みのことです。Map はキーの型 K と値の型 V の2つ、Set は要素の型 T を1つ受け取ります。new のうしろに <> で型を書けば、その MapSet に入れられる値が固定されます。

basic.ts
// キーが 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);

型引数はプリミティブ型だけでなく、ユニオン型やインターフェースなど好きな型を指定できます。キーにユニオン型を指定すると、そこに無い値を書いた時点でエラーになるので、キーの打ち間違いをコンパイル時に潰せます。

union-key.ts
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 と推論します。標準ライブラリの MapConstructornew (): Map<any, any>; という引数なしのオーバーロードが定義されているためです。any は「型チェックを放棄する型」なので、何を入れても何を取り出しても一切怒られません。

any-map.ts
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);          // 型としては通る。中身が数値でなければ実行時エラー

やっかいなのは、strictnoImplicitAny)を有効にしていても、このコードが1つもエラーにならない点です。noImplicitAny が検出するのは「型注釈が無くて暗黙に any になった変数や引数」であって、ここでは型定義側が明示的に any を返しているため対象外になります。つまりコンパイラに任せていても気づけないので、空の Map を作るときは自分で型引数を書く必要があります。

一方で new Set()Set<unknown> になります。unknown は「何でも入るが、取り出したあと何もできない型」なので、追加はできても使おうとした時点でエラーになり、型引数の書き忘れに気づけます。同じ書き忘れでも Map のほうが静かに壊れる、と覚えておくとよいでしょう。

unknown-set.ts
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 するから」と空配列を渡すのは避け、型引数を明示してください。

infer.ts
// 初期値から 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 を作りたいなら、型引数のほうでユニオン型を明示します。

mixed.ts
// 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 を付けます。読み取り専用のタプルとして扱われるようになり、リテラル型がそのまま型引数に入ります。

as-const.ts
// 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)ではないためです。

get.ts
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 チェックが散らばるのを防げます。

get-safe.ts
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 であることが前提です。

ループと配列化での型

Mapfor...of で回すと [キー, 値] のタプルが取り出せます。分割代入で受ければ、それぞれ KV の型が付くので、追加の型注釈は要りません。keys() / values() / entries() は TypeScript 5.6 以降では MapIterator<T>Set なら SetIterator<T>)という型を返しますが、これは IterableIterator を拡張したものなので、使い方はこれまでと変わりません。

iterate.ts
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);
});

MapSet には map()filter() がありません。並べ替えたり加工したりしたいときは、いったん配列にします。スプレッド構文と Array.from() のどちらでも型は正しく付き、Map を配列化すると [K, V][]Set なら T[] になります。

to-array.ts
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 でエラーになるとき

MapSet は ES2015(ES6)で追加された機能なので、tsconfig.jsontargetlib の設定が古いとコンパイルエラーになります。エラーメッセージが2種類あり、原因も対処も違うので分けて説明します。

Cannot find name ‘Map’. と言われる(lib の問題)

lib は「どの標準ライブラリの型定義を読み込むか」の設定です。これが es5 のままだと MapSet の型定義自体が存在せず、TS2583 のエラーになります。lib を省略している場合は target に応じた既定値が使われるので、targetes2015 以上にするだけでも解決します。

エラー例(lib: es5)
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 が出る場合は、targetes5 になっているのが原因です。ES5 にはイテレータの構文が無いため、TypeScript は Map のような「配列ではない反復可能オブジェクト」を安全に展開できません。targetes2015 以上に上げるか、どうしても ES5 で出力する必要があるなら downlevelIteration を有効にします(ヘルパーコードが出力に追加され、その分だけ生成される JavaScript は大きくなります)。

エラー例(target: es5)
error TS2802: Type 'Map<string, number>' can only be iterated through when using
the '--downlevelIteration' flag or with a '--target' of 'es2015' or higher.
tsconfig.json
{
  "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> に代入できるので、「受け取るだけで書き換えない」関数の引数に付けておくと意図が伝わります。

readonly.ts
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-guard.ts
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 や個別指定)
要素数sizeObject.keys(obj).length
キーの順序挿入順が保たれる整数風のキーが先に並ぶなど独自の規則がある
JSON 化JSON.stringify(){} になるそのまま出力される
頻繁な追加・削除得意(delete 演算子が不要)可能だが delete が必要

キーの顔ぶれがコンパイル時に決まっているなら Record、実行時に増減する動的なデータなら Map、というのが基本的な指針です。ステータスごとのラベルのように「全パターンを必ず書いてほしい」ものは Record にしておくと、キーを1つ増やしたときに書き漏らしがエラーになります。逆にユーザー ID をキーにしたキャッシュのような用途は Map の出番です。

record-vs-map.ts
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> から作った MapMap<Status, string> ではなく Map<string, string> になります。キーの型を保ちたければ型引数を明示するのが確実です。

convert.ts
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 は既定ではオブジェクト型を指します(libesnext などを含めると、シンボルをキーにできる ES2023 の仕様が反映されて symbol も含まれるようになります)。文字列や数値を渡すとコンパイルエラーです。

weak.ts
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

もうひとつ知っておきたいのは、WeakMapWeakSet には sizekeys()forEach() も無く、for...of で回せないことです(いつ回収されるか分からないため、列挙できると挙動が不安定になるからです)。使えるのは WeakMap なら get / set / has / deleteWeakSet なら add / has / delete だけです。中身を数えたり列挙したりする必要があるなら、通常の Map / Set を選んでください。なお、型引数を省いた new WeakMap() は値の型が any になるので、こちらも明示するのが安全です。

まとめ

MapSet はジェネリック型なので、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 にする、と考えれば選択に迷いません。

参考ページ