1. ホーム
  2. TypeScript

【TypeScript】symbol・unique symbol の使い方|衝突しないキーと型で個体を区別する仕組みを解説

Share

TypeScript には stringnumber と並ぶプリミティブ型として symbol があります。あまり出番がないように見えますが、「他と絶対にかぶらないオブジェクトのキー」を作りたいときに役立つ型です。さらに TypeScript には、JavaScript にはない unique symbol という特別な型が用意されていて、これを理解しないと「型 ‘symbol’ をインデックスの型として使用できません」といったエラーでつまずきます。この記事では、symbol 型の基本から unique symbol との違い、オブジェクトのキーとして使うときの書き方までを、動くコードで解説します。対象は TypeScript の基本的な型注釈を書ける初心者〜中級者の方です。

symbol とは(必ず一意になる値)

symbol は ECMAScript 2015 で追加されたプリミティブ型で、Symbol() 関数を呼ぶたびに他のどの値とも等しくならない新しい値が作られます。引数に渡した文字列は説明(description)としてデバッグ時に表示されるだけで、値の同一性には関係ありません。同じ説明で2回作っても別物になる、という点がこの型のすべてです。

basic.ts
// Symbol() を呼ぶたびに新しい値ができる
const a = Symbol('id');
const b = Symbol('id');

console.log(a === b); // false(説明が同じでも別の値)
console.log(a.description); // 'id'(デバッグ用の説明)

// 型注釈を書くなら symbol
const key: symbol = Symbol('key');

new Symbol() のように new を付けて呼ぶことはできません。Symbol はコンストラクタではなく関数として呼び出す決まりで、TypeScript でも「’Symbol’ 型のオブジェクトはコンストラクト シグネチャを持ちません」というエラーになります。必ず Symbol('...') の形で作ってください。

何がうれしいのか(キーがぶつからない)

文字列をオブジェクトのキーに使っていると、別の場所から同じ名前のキーを書き込まれて値が上書きされることがあります。とくに、外部から渡ってきたオブジェクトに自分のライブラリ用の情報をこっそり足したいときや、フレームワークが内部的に使う印を付けたいときに困ります。symbol をキーにすれば、その値を持っている人しかアクセスできないため、名前の衝突が原理的に起こりません。

collision.ts
type User = { name: string };

// 文字列キーだと、他のコードから同じ 'cache' で上書きされうる
const user1: User & { cache?: string } = { name: 'yamada' };
user1.cache = '内部用の値';

// symbol キーなら、この cacheKey を知らないコードからは触れない
const cacheKey = Symbol('cache');
const user2 = { name: 'yamada', [cacheKey]: '内部用の値' };

console.log(user2[cacheKey]); // '内部用の値'
console.log(Object.keys(user2)); // ['name'] ← symbol キーは列挙されない

最後の行のとおり、symbol をキーにしたプロパティは Object.keys()for...inJSON.stringify() の対象になりません。取得したいときは Object.getOwnPropertySymbols() を使います。「普通に列挙したときには出てこない、隠しプロパティのようなもの」と考えると分かりやすいでしょう。ただし完全な privateではなく、getOwnPropertySymbols を使えば取り出せる点は覚えておいてください。

unique symbol とは(TypeScript だけにある型)

ここからが TypeScript 特有の話です。symbol という型は「なんらかのシンボル」を表すだけで、どのシンボルなのかを区別できません。そのため、型の世界で「このシンボルをキーに持つオブジェクト」と書きたいときに困ります。そこで用意されたのが unique symbol で、これは「その1つの宣言だけを指す型」です。

unique symbol になれる場所は限られていて、const 宣言か、readonly static なクラスプロパティに Symbol() / Symbol.for() の呼び出しを代入したときだけです。letvar では(あとから別の値を入れられてしまうため)unique symbol にはなりません。

unique-symbol.ts
// const に代入すると、型は unique symbol になる
const idKey = Symbol('id');
//    ^? typeof idKey は unique symbol

// let にすると、ただの symbol 型になる
let looseKey = Symbol('loose');
//  ^? symbol

// 明示的に注釈するときは typeof を使わず unique symbol と書く
declare const brand: unique symbol;

// unique symbol 同士でも、宣言が違えば型として互換性がない
const otherKey = Symbol('id');
// idKey = otherKey; // エラー: 型 'typeof otherKey' を 'typeof idKey' に割り当てられません

2つの symbolunique symbol の違いを整理すると次のようになります。「値としては同じシンボルだが、型として個体を区別できるかどうかが違う」と押さえてください。

書き方付く型プロパティキーに使えるか
const k = Symbol()unique symbol使える(型でも参照できる)
let k = Symbol()symbol値としては使えるが、型では書けない
const k: symbol = Symbol()symbol注釈で広げているので型では書けない
static readonly k = Symbol()unique symbol使える

型定義のキーとして使う

unique symbol の本領は、インターフェースや型エイリアスのプロパティ名として使えることです。角括弧で囲んで [idKey]: number のように書くと、「このシンボルをキーに持つ」という型を表現できます。

typed-key.ts
export const idKey = Symbol('id');

// unique symbol なので、型の中でキーとして書ける
type Entity = {
  name: string;
  [idKey]: number;
};

const user: Entity = {
  name: 'yamada',
  [idKey]: 1,
};

console.log(user[idKey]); // 1(number 型として推論される)

// キーを書き忘れるとコンパイルエラーになる
// const ng: Entity = { name: 'sato' };
// エラー: プロパティ '[idKey]' が型に存在しません

この書き方は、いわゆる「ブランド(branded type)」にも応用されます。declare const brand: unique symbol のように値を持たないシンボルを宣言し、type UserId = string & { [brand]: 'UserId' } と交差させると、実行時には string のままなのに、型の上では他の文字列と区別できる ID 型が作れます。

Symbol.for とグローバルなシンボル

Symbol() で作ったシンボルはその場限りのものですが、Symbol.for('キー') を使うと、グローバルなシンボルレジストリという共有の置き場からシンボルを取得できます。同じ文字列で呼べば同じシンボルが返るため、モジュールをまたいでも、極端に言えばフレームの違うコードからでも同じシンボルを共有できます。

symbol-for.ts
const g1 = Symbol.for('app.session');
const g2 = Symbol.for('app.session');

console.log(g1 === g2); // true(レジストリから同じものが返る)
console.log(Symbol.keyFor(g1)); // 'app.session'

// Symbol() は毎回別物なので keyFor は undefined
console.log(Symbol.keyFor(Symbol('app.session'))); // undefined

共有できるのは便利ですが、文字列で引ける以上「他のライブラリと同じキー名を使ってしまう」衝突のリスクは戻ってきます。用途を限定し、キー名にはパッケージ名などの接頭辞を付けるのが安全です。衝突を避けたいだけなら、素直に Symbol() を使ってその値を export しましょう。

「型 ‘symbol’ をインデックスの型として使用できません」と出るとき

const に型注釈を書いてしまっている

もっとも多い原因がこれです。const key: symbol = Symbol('key') と書くと、せっかくの unique symbol がわざわざ広い symbol 型に上書きされてしまい、型定義のキーとして使えなくなります。Symbol() の戻り値は最初から適切に推論されるので、型注釈は書かないのが正解です。

error.ts
// NG: 注釈で symbol に広がってしまう
const ngKey: symbol = Symbol('ng');
// type NG = { [ngKey]: number };
// エラー: 型 'symbol' の計算されたプロパティ名はリテラル型ではありません

// OK: 注釈を外せば unique symbol になる
const okKey = Symbol('ok');
type OK = { [okKey]: number };

let や関数の引数で受け取っている

let で宣言した変数や、関数の引数として受け取ったシンボルは symbol 型どまりなので、型定義のキーには書けません。あとから再代入される可能性があるものを「特定のキー」として扱うことはできない、というのが理由です。キーとして型に登場させたいシンボルは、モジュールのトップレベルで const 宣言して export し、それを import して使う形にそろえましょう。

任意のシンボルをキーにしたいだけのとき

特定のシンボルではなく「シンボルなら何でもキーにできるオブジェクト」を表したい場合は、インデックスシグネチャで { [key: symbol]: string } と書けます(TypeScript 4.4 以降)。あるいは Record<symbol, string> でも同じ意味になります。この用途なら unique symbol は不要です。

まとめ

symbolSymbol() を呼ぶたびに一意な値が作られるプリミティブ型で、名前が衝突しないオブジェクトのキーとして役立ちます。symbol キーのプロパティは Object.keys()JSON.stringify() に現れないため、内部用の情報を持たせる用途にも向いています。TypeScript ではさらに unique symbol という型があり、const 宣言か static readonly プロパティに Symbol() を代入したときだけ付きます。この型になっていれば、type Entity = { [idKey]: number } のように型定義のプロパティ名として使えます。逆に const key: symbol = ... と注釈を書いたり let で宣言したりすると symbol 型に広がってキーとして使えなくなるので注意してください。モジュールをまたいで同じシンボルを共有したいときは Symbol.for()、任意のシンボルをキーにしたいだけなら { [key: symbol]: T } と使い分けましょう。

参考ページ