CLOVER🍀

That was when it all began.

JavaScriptのMapやSetをJSONやObjectあたりと相互変換したいという話

これは、なにをしたくて書いたもの?

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から見ていきましょう。

Map - JavaScript | MDN

そもそもJSON#stringifyでのシリアライズやJSON#parseでのパースに対応していないよ、とMDNのMapのページに書いてありました。

シリアライズや解釈のためのネイティブな対応はありません。

(ただし、 replacer 引数で JSON.stringify() を使用し、 reviver 引数で JSON.parse() を使用することで、 Map のために、独自のシリアライズと解釈の対応を作成することができます。 Stack Overflow の質問 How do you JSON.stringify an ES6 Map? を参照してください。)

Map / 解説 / Object と 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にはシリアライズについては特に書かれていません。

Set - JavaScript | MDN

ですが、理屈はMapと似たようなもので、Setについては1度Arrayを間に挟めばよさそうです。

Setはiterableですし、コンストラクターで配列を受け取って配列からSetに変換することもできます。

Set() コンストラクター - JavaScript | MDN

Setから配列への変換は、スプレッド演算子を使うと簡単そうですね。

スプレッド構文 - JavaScript | MDN

参考)
- Set / Setの操作 / Setを配列に変換する - Set / Setは直接JSONにできない

冷静に考えると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? を参照してください。)

Map / 解説 / Object と 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を含めない方が)いいのかなと思いました…。