これは、なにをしたくて書いたもの?
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
を含めない方が)いいのかなと思いました…。