これは、なにをしたくて書いたもの?
JavaScriptのMapやSetをJSON#stringifyでJSON文字列にすると、思わぬ結果になったのでこのあたりをちょっとメモしておこうかなと。
こんな感じですね。
// Map test('apply JSON.stringify to Map', () => { const map = new Map([ ['one', 1], ['two', 2], ['three', 3], ]); expect(JSON.stringify(map)).toBe('{}'); }); // Set test('apply JSON.strinfigy to Set', () => { const set = new Set(['JavaScript', 'TypeScript', 'Node.js']); expect(JSON.stringify(set)).toBe('{}'); });
MapとObject、SetとArray
最初に書いたように、MapやSetをJSON文字列にしようとすると、初見ではちょっと驚く(?)結果になります。
// Map test('apply JSON.stringify to Map', () => { const map = new Map([ ['one', 1], ['two', 2], ['three', 3], ]); expect(JSON.stringify(map)).toBe('{}'); }); // Set test('apply JSON.strinfigy to Set', () => { const set = new Set(['JavaScript', 'TypeScript', 'Node.js']); expect(JSON.stringify(set)).toBe('{}'); });
値がなにも入らずに{}になりましたね…。
Map
まずMapから見ていきましょう。
そもそもJSON#stringifyでのシリアライズやJSON#parseでのパースに対応していないよ、とMDNのMapのページに書いてありました。
シリアライズや解釈のためのネイティブな対応はありません。
(ただし、 replacer 引数で JSON.stringify() を使用し、 reviver 引数で JSON.parse() を使用することで、 Map のために、独自のシリアライズと解釈の対応を作成することができます。 Stack Overflow の質問 How do you JSON.stringify an ES6 Map? を参照してください。)
MapのシリアライズについてはJSON#stringifyのreplacer引数、パースにはJSON#parseのreviver引数を使うと良い、と書かれて
いるのですが、もう少し手段があるようです。
Object#fromEntriesを使って、1度Objectに変換すればよいみたいです。Object#fromEntriesは、iterableなオブジェクトをキーと値の
ペアからなる配列をオブジェクトに変換します。
Mapはiterableです。
Object.fromEntries() - JavaScript | MDN
というか、その用途そのものが書いていますね。
Object.fromEntries() / 例 / Map から Object への変換
Objectに変換した後であれば、JSON#stringifyでJSON文字列にできます。…当たり前といえば、当たり前ですが。
JSON#parseの時はどうしたらいいかというと、Object#entriesでオブジェクトをキーと値のペアの配列に変換します。
Object.entries() - JavaScript | MDN
こちらも、その用途そのものが書いています。
Object.entries() / 例 / Object から Map への変換
こうすれば、あとはMapのコンストラクターに渡せばMapとして構成できます。
Map() コンストラクター - JavaScript | MDN
Objectを1度経由することで、MapとJSON文字列の変換を行うことになります。
参考)
- Map<K, V> / Mapは直接JSONにできない
- Map<K, V> / 他の型との相互運用
Set
Setにはシリアライズについては特に書かれていません。
ですが、理屈はMapと似たようなもので、Setについては1度Arrayを間に挟めばよさそうです。
Setはiterableですし、コンストラクターで配列を受け取って配列からSetに変換することもできます。
Set() コンストラクター - JavaScript | MDN
Setから配列への変換は、スプレッド演算子を使うと簡単そうですね。
参考)
- Set
冷静に考えるとMapもSetもJSONの表現の範囲外なので直接変換しようとしてもムリなのはそうですね、間にJSONで表現可能なものを
挟めばそりゃあできますよね、という感じなのですが。
特にMapがJSON#stringifyで変換できないのは、なにも考えない状態で見ると割と驚く気がします。
というわけで、簡単に試してみます。Node.js+TypeScriptの環境で確認することにします。
環境
今回の環境は、こちら。
$ node --version v18.15.0 $ npm --version 9.5.0
Node.jsプロジェクトを作成する
Node.jsプロジェクトを作成します。一緒にTypeScript、テストコードでの確認ということでJestもインストール。
$ npm init -y $ npm i -D typescript $ npm i -D @types/node@v18 $ npm i -D prettier $ npm i -D jest @types/jest $ npm i -D esbuild esbuild-jest $ mkdir test
依存関係。
"devDependencies": { "@types/jest": "^29.5.0", "@types/node": "^18.15.5", "esbuild": "^0.17.12", "esbuild-jest": "^0.5.0", "jest": "^29.5.0", "prettier": "^2.8.6", "typescript": "^5.0.2" }
scripts。
"scripts": { "build": "tsc --project .", "build:watch": "tsc --project . --watch", "typecheck": "tsc --project ./tsconfig.typecheck.json", "typecheck:watch": "tsc --project ./tsconfig.typecheck.json --watch", "test": "jest", "format": "prettier --write src test" },
設定ファイル。
tsconfig.json
{ "compilerOptions": { "target": "esnext", "module": "commonjs", "moduleResolution": "node", "lib": ["esnext"], "baseUrl": "./src", "outDir": "dist", "strict": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, "noImplicitReturns": true, "noPropertyAccessFromIndexSignature": true, "esModuleInterop": true }, "include": [ "src" ] }
tsconfig.typecheck.json
{ "extends": "./tsconfig", "compilerOptions": { "baseUrl": "./", "noEmit": true }, "include": [ "src", "test" ] }
.prettierrc.json
{ "singleQuote": true, "printWidth": 120 }
jest.config.js
module.exports = { testEnvironment: 'node', transform: { "^.+\\.tsx?$": "esbuild-jest" } };
この環境で試していきましょう。
Map
まずはMapから。
そのままJSON#stringify、JSON#parse
最初はなにも考えずにMapをJSON#stringifyでJSON文字列に変換してみます。これは最初に見ましたが、{}になります。
test('apply JSON.stringify to Map', () => { const map = new Map([ ['one', 1], ['two', 2], ['three', 3], ]); expect(JSON.stringify(map)).toBe('{}'); });
また、JSON#parseでムリヤリMapにキャストしたところで、実体はオブジェクトなのでMapのインスタンスではありません。
test('parse JSON as Map with JSON.parse', () => { const map = JSON.parse('{"one":1,"two":2,"three":3}') as Map<string, number>; expect(map).toEqual({ one: 1, two: 2, three: 3 }); expect(map).not.toBeInstanceOf(Map); expect(() => map.has('one')).toThrow(new TypeError('map.has is not a function')); });
Mapのメソッドを使ったところで、例外がスローされます。
Object#fromEntries+JSON#stringify、JSON#parse+Object#entries+Mapコンストラクター
次は、MapとJSON文字列の変換の間に、Object#fromEntriesやObject#entriesを挟んでみます。
MapからJSON文字列への変換。
test('apply JSON.stringify to Object.fromEntries(Map)', () => {
const map = new Map([
['one', 1],
['two', 2],
['three', 3],
]);
expect(JSON.stringify(Object.fromEntries(map))).toBe('{"one":1,"two":2,"three":3}');
});
JSON#stringifyの前に、MapをObject#fromEntriesでオブジェクトに変換することでJSON文字列にシリアライズできました。
次はJSON文字列からMapへの変換。
test('parse JSON as Object with JSON.parse, convert to Map', () => { const map = new Map<string, number>(Object.entries(JSON.parse('{"one":1,"two":2,"three":3}'))); expect(map).toEqual( new Map([ ['one', 1], ['two', 2], ['three', 3], ]) ); expect(map).toBeInstanceOf(Map); });
ここでは、JSON#parseの結果(オブジェクト)をObject#entriesでキーと値のペアの配列に変換して、そこからMapに再構成して
います。
これで、MapをJSON文字列に変換したり、その逆ができるようになりました。
Set
続いてはSet。
そのままJSON#stringify、JSON#parse
こちらも、最初はなにも考えずにMapをJSON#stringifyでJSON文字列に変換してみます。こちらも{}になります。
test('apply JSON.strinfigy to Set', () => { const set = new Set(['JavaScript', 'TypeScript', 'Node.js']); expect(JSON.stringify(set)).toBe('{}'); });
またJSON#parseでムリヤリSetにキャストしたところで、実体は配列なのでSetのインスタンスとしては扱われません。
test('parse JSON as Set with JSON.parse', () => { const set = JSON.parse('["JavaScript","TypeScript","Node.js"]') as Set<string>; expect(set).toEqual(['JavaScript', 'TypeScript', 'Node.js']); expect(set).not.toBeInstanceOf(Set); expect(set).toBeInstanceOf(Array); expect(() => set.has('JavaScript')).toThrow(new TypeError('set.has is not a function')); });
配列変換+JSON#stringify、JSON#parse+Setコンストラクター
では、SetとJSON文字列の間に1度明示的に配列への変換を挟むことで、これらの問題を解消したいと思います。
まずはJSON#stringifyの前に、スプレッド演算子と組み合わせてSetを配列に変換。
test('convert Set to Array, apply JSON.strinfigy', () => { const set = new Set(['JavaScript', 'TypeScript', 'Node.js']); expect(JSON.stringify([...set])).toBe('["JavaScript","TypeScript","Node.js"]'); });
配列をJSON文字列にしているので、配列のJSON文字列表現になりましたね。
JSON#parseの場合は、配列をSetに変換すればよいので、そのままSetのコンストラクターに渡します。
test('parse JSON as Array with JSON.parse, convert to Set', () => { const set = new Set<string>(JSON.parse('["JavaScript","TypeScript","Node.js"]')); expect(set).toEqual(new Set(['JavaScript', 'TypeScript', 'Node.js'])); expect(set).toBeInstanceOf(Set); expect(set).not.toBeInstanceOf(Array); });
これで、配列を経由してJSON文字列をSetに変換できました。
もう少し
最初にMapのドキュメントでシリアライズについて見た時、以下のような記述がありました。
シリアライズや解釈のためのネイティブな対応はありません。
(ただし、 replacer 引数で JSON.stringify() を使用し、 reviver 引数で JSON.parse() を使用することで、 Map のために、独自のシリアライズと解釈の対応を作成することができます。 Stack Overflow の質問 How do you JSON.stringify an ES6 Map? を参照してください。)
ここで書かれているStack Overflowの質問は、以下になります。
javascript - How do you JSON.stringify an ES6 Map? - Stack Overflow
それぞれ以下のような関数を作成し、シリアライズ、パース処理をカスタマイズしようという話です。
// JSON#stringifyで使う function replacer(key, value) { if(value instanceof Map) { return { dataType: 'Map', value: Array.from(value.entries()), // or with spread: value: [...value] }; } else { return value; } } // JSON#parseで使う function reviver(key, value) { if(typeof value === 'object' && value !== null) { if (value.dataType === 'Map') { return new Map(value.value); } } return value; }
この関数を使ってMapをシリアライズすると、以下のように型情報を埋め込んだ形になります。
{ "dataType": "Map", "value": [[key, value],[key,value],...] }
パースの時には、この型情報を見てMapに戻そうとするわけですね。
JSON#stringifyにおけるreplacer関数はオプションの引数で、JSON文字列に変換する際の挙動をカスタマイズできます。
JSON.stringify() - JavaScript | MDN
JSON#parseにおけるreviver関数も同じくオプションの引数で、JSON文字列からオブジェクトに変換する際の処理をカスタマイズ
できます。
JSON.parse() - JavaScript | MDN
このあたりを使わなくても、先ほどまでに書いたObject#fromEntriesなどを使えばいいのでは?という気がしますが、たとえば
JSON文字列化する対象のオブジェクトのプロパティにMapがあったりすると、Object#fromEntriesではうまくいきません。
これで効果があるのは、トップレベルのオブジェクトがMapだった場合ですからね。
というわけで、今回はMapで簡単に試すことにします。
まずはJSON#stringfy+replacer関数。
test('apply JSON.stringify to Object include Map', () => { const book = { isbn: '978-4873119700', title: 'JavaScript 第7版', price: 5060, tags: new Map([ ['language', 'JavaScript'], ['category', 'Programming'], ]), }; expect(JSON.stringify(book)).toBe('{"isbn":"978-4873119700","title":"JavaScript 第7版","price":5060,"tags":{}}'); const replacer = (key: any, value: any) => { if (value instanceof Map) { return Object.fromEntries(value); } return value; }; expect(JSON.stringify(book, replacer)).toBe( '{"isbn":"978-4873119700","title":"JavaScript 第7版","price":5060,"tags":{"language":"JavaScript","category":"Programming"}}' ); });
こんな感じで、Mapをプロパティに持つオブジェクトでも問題なくJSON文字列にできました。今回は、型情報をJSONに埋め込むことは
していません。
次はJSON#parse+reviver関数。
test('parse JSON as Object with JSON.parse, convert to Object include Map', () => { const bookString = '{"isbn":"978-4873119700","title":"JavaScript 第7版","price":5060,"tags":{"language":"JavaScript","category":"Programming"}}'; const bookAsObject = JSON.parse(bookString); expect(bookAsObject.tags).not.toBeInstanceOf(Map); expect(bookAsObject).not.toEqual({ isbn: '978-4873119700', title: 'JavaScript 第7版', price: 5060, tags: new Map([ ['language', 'JavaScript'], ['category', 'Programming'], ]), }); expect(bookAsObject).toEqual({ isbn: '978-4873119700', title: 'JavaScript 第7版', price: 5060, tags: { language: 'JavaScript', category: 'Programming', }, }); const revivier = (key: any, value: any) => { if (key === 'tags') { return new Map(Object.entries(value)); } return value; }; const book = JSON.parse(bookString, revivier); expect(book).toEqual({ isbn: '978-4873119700', title: 'JavaScript 第7版', price: 5060, tags: new Map([ ['language', 'JavaScript'], ['category', 'Programming'], ]), }); expect(book.tags).toBeInstanceOf(Map); });
この例では型情報がないので、プロパティ名で決め打ちでMapに変換するようにしています。
ちなみに、ここまで書くとなんとなく気づくのですが、トップレベルのオブジェクトがMapであってもreplacer関数でシリアライズや
reviver関数でパースすることができます。
こんな感じですね。
## JSON#stringify+replacer test('apply JSON.stringify with replacer to Object include Map', () => { const map = new Map([ ['one', 1], ['two', 2], ['three', 3], ]); const replacer = (key: any, value: any) => { if (value instanceof Map) { expect(key).toBe(''); return Object.fromEntries(value); } return value; }; expect(JSON.stringify(map, replacer)).toBe('{"one":1,"two":2,"three":3}'); }); ## JSON#parse+reviver test('parse JSON as Map with JSON.parse with revivier', () => { const mapAsString = '{"one":1,"two":2,"three":3}'; const revivier = (key: any, value: any) => { if (key === '') { return new Map(Object.entries(value)); } return value; }; const map = JSON.parse(mapAsString, revivier); expect(map).toEqual( new Map([ ['one', 1], ['two', 2], ['three', 3], ]) ); expect(map).toBeInstanceOf(Map); });
まあ、手間が増えるだけなのでやらないでしょうけどね…。
キーがない場合は、空文字列が渡されるようですね。
今回は、こんなところで。
まとめ
MapやSetをJSON文字列にシリアライズ、パースしようとしてハマったので、ちょっとまとめておきました。
対応方法はわかったのですが、ネストしたプロパティにMapやSetがいたりすると厄介なことになるので、JSONに変換するオブジェクトは
素直にJSONで表現できる範囲にとどめた方が(MapやSetを含めない方が)いいのかなと思いました…。