Repository: blitz-js/superjson (ts)

Issue:
Add support for non-uniformly typed Maps
At the moment, we don't support `Map<number | string, RegExp>`, because the keys of `Map { 1 => /a/g, "1" => /b/g }` would clash, as both keys serialise to `{ "1": "/a/g" }` / `{ "1": "/b/g" }`.

The same holds true for other key type combinations, of course.

Pull request:
Serialize `Map` using a list of pairs
Closes #33, #35 

While we had been trying to fit Map's semantics into JSON's object, the solution had been in front of us all along: It's impossible!
That's why `new Map(...)` takes a list of pairs instead of an object.

This PR suggests to also use a list of pairs to serialize `Map`, which allows us to distinguish different objects from one another and also support non-primitive values.

This PR may be a bit hard to comprehend, so please go forward and ask all the questions you like ^^

Patch:
diff --git a/src/accessDeep.ts b/src/accessDeep.ts
index 68bb94c..195d860 100644
--- a/src/accessDeep.ts
+++ b/src/accessDeep.ts
@@ -1,3 +1,15 @@
+import { isMap, isArray, isPlainObject, isSet } from './is';
+
+export const getNthKey = (value: Map<any, any> | Set<any>, n: number): any => {
+  const keys = value.keys();
+  while (n > 0) {
+    keys.next();
+    n--;
+  }
+
+  return keys.next().value;
+};
+
 export const getDeep = (object: object, path: (string | number)[]): object => {
   for (const key of path) {
     object = (object as any)[key];
@@ -7,20 +19,87 @@ export const getDeep = (object: object, path: (string | number)[]): object => {
 };
 
 export const setDeep = (
-  object: object,
+  object: any,
   path: (string | number)[],
   mapper: (v: any) => any
-): object => {
+): any => {
   if (path.length === 0) {
     return mapper(object);
   }
 
-  const front = path.slice(0, path.length - 1);
-  const last = path[path.length - 1];
+  let parent = object;
 
-  const parent: any = getDeep(object, front);
+  for (let i = 0; i < path.length - 1; i++) {
+    const key = path[i];
+
+    if (isArray(parent)) {
+      const index = +key;
+      parent = parent[index];
+    } else if (isPlainObject(parent)) {
+      parent = parent[key];
+    } else if (isSet(parent)) {
+      const row = +key;
+      parent = getNthKey(parent, row);
+    } else if (isMap(parent)) {
+      const isEnd = i === path.length - 2;
+      if (isEnd) {
+        break;
+      }
+
+      const row = +key;
+      const type = +path[i + 1] === 0 ? 'key' : 'value';
+
+      const keyOfRow = getNthKey(parent, row);
+      switch (type) {
+        case 'key':
+          parent = keyOfRow;
+          break;
+        case 'value':
+          parent = parent.get(keyOfRow);
+          break;
+      }
+
+      i++;
+    }
+  }
 
-  parent[last] = mapper(parent[last]);
+  const lastKey = path[path.length - 1];
+
+  if (isArray(parent) || isPlainObject(parent)) {
+    parent[lastKey] = mapper(parent[lastKey]);
+  }
+
+  if (isSet(parent)) {
+    const oldValue = getNthKey(parent, +lastKey);
+    const newValue = mapper(oldValue);
+    if (oldValue !== newValue) {
+      parent.delete(oldValue);
+      parent.add(newValue);
+    }
+  }
+
+  if (isMap(parent)) {
+    const row = +path[path.length - 2];
+    const keyToRow = getNthKey(parent, row);
+
+    const type = +lastKey === 0 ? 'key' : 'value';
+    switch (type) {
+      case 'key': {
+        const newKey = mapper(keyToRow);
+        parent.set(newKey, parent.get(keyToRow));
+
+        if (newKey !== keyToRow) {
+          parent.delete(keyToRow);
+        }
+        break;
+      }
+
+      case 'value': {
+        parent.set(keyToRow, mapper(parent.get(keyToRow)));
+        break;
+      }
+    }
+  }
 
   return object;
 };
diff --git a/src/plainer.ts b/src/plainer.ts
index 78b263f..8246021 100644
--- a/src/plainer.ts
+++ b/src/plainer.ts
@@ -50,9 +50,16 @@ export const plainer = (
     );
   }
 
-  if (isPlainObject(object) || isMap(object)) {
+  if (isMap(object)) {
+    return IteratorUtils.map(entries(object), ([key, value], index) => [
+      plainer(key, walker, [...path, index, 0], alreadySeenObjects),
+      plainer(value, walker, [...path, index, 1], alreadySeenObjects),
+    ]);
+  }
+
+  if (isPlainObject(object)) {
     return Object.fromEntries(
-      IteratorUtils.map(entries(object), ([key, value]) => [
+      Object.entries(object).map(([key, value]) => [
         key,
         plainer(value, walker, [...path, key], alreadySeenObjects),
       ])
diff --git a/src/transformer.ts b/src/transformer.ts
index f8acd53..bdf0628 100644
--- a/src/transformer.ts
+++ b/src/transformer.ts
@@ -1,41 +1,26 @@
 import {
   isBigint,
-  isBoolean,
   isDate,
   isInfinite,
   isMap,
   isNaNValue,
-  isNumber,
   isRegExp,
   isSet,
-  isString,
   isUndefined,
 } from './is';
+import * as IteratorUtils from './iteratorutils';
 
-export type PrimitiveTypeAnnotation =
-  | 'NaN'
-  | '-Infinity'
-  | 'Infinity'
-  | 'undefined'
-  | 'bigint';
+export type PrimitiveTypeAnnotation = 'number' | 'undefined' | 'bigint';
 
 type LeafTypeAnnotation = PrimitiveTypeAnnotation | 'regexp' | 'Date';
 
-type MapTypeAnnotation =
-  | 'map:number'
-  | 'map:string'
-  | 'map:bigint'
-  | 'map:boolean';
-
-type ContainerTypeAnnotation = MapTypeAnnotation | 'set';
+type ContainerTypeAnnotation = 'map' | 'set';
 
 export type TypeAnnotation = LeafTypeAnnotation | ContainerTypeAnnotation;
 
 const ALL_PRIMITIVE_TYPE_ANNOTATIONS: TypeAnnotation[] = [
-  '-Infinity',
-  'Infinity',
   'undefined',
-  'NaN',
+  'number',
   'bigint',
 ];
 
@@ -46,15 +31,7 @@ export const isPrimitiveTypeAnnotation = (
 };
 
 const ALL_TYPE_ANNOTATIONS: TypeAnnotation[] = ALL_PRIMITIVE_TYPE_ANNOTATIONS.concat(
-  [
-    'map:number',
-    'map:string',
-    'map:bigint',
-    'map:boolean',
-    'regexp',
-    'set',
-    'Date',
-  ]
+  ['map', 'regexp', 'set', 'Date']
 );
 
 export const isTypeAnnotation = (value: any): value is TypeAnnotation => {
@@ -81,13 +58,13 @@ export const transformValue = (
     };
   } else if (isNaNValue(value)) {
     return {
-      value: undefined,
-      type: 'NaN',
+      value: 'NaN',
+      type: 'number',
     };
   } else if (isInfinite(value)) {
     return {
-      value: undefined,
-      type: value > 0 ? 'Infinity' : '-Infinity',
+      value: value > 0 ? 'Infinity' : '-Infinity',
+      type: 'number',
     };
   } else if (isSet(value)) {
     return {
@@ -100,34 +77,11 @@ export const transformValue = (
       type: 'regexp',
     };
   } else if (isMap(value)) {
-    const { done: valueIsEmpty, value: firstKey } = value.keys().next();
-    const returnValueDoesntMatter = valueIsEmpty;
-    if (returnValueDoesntMatter || isString(firstKey)) {
-      return { value, type: 'map:string' };
-    }
-
-    if (isNumber(firstKey)) {
-      return {
-        value: value,
-        type: 'map:number',
-      };
-    }
-
-    if (isBigint(firstKey)) {
-      return {
-        value: value,
-        type: 'map:bigint',
-      };
-    }
-
-    if (isBoolean(firstKey)) {
-      return {
-        value: value,
-        type: 'map:boolean',
-      };
-    }
-
-    throw new Error('Key type not supported.');
+    const entries = IteratorUtils.map(value.entries(), pair => pair);
+    return {
+      value: entries,
+      type: 'map',
+    };
   }
 
   return undefined;
@@ -141,20 +95,10 @@ export const untransformValue = (json: any, type: TypeAnnotation) => {
       return undefined;
     case 'Date':
       return new Date(json as string);
-    case 'NaN':
-      return Number.NaN;
-    case 'Infinity':
-      return Number.POSITIVE_INFINITY;
-    case '-Infinity':
-      return Number.NEGATIVE_INFINITY;
-    case 'map:number':
-      return new Map(Object.entries(json).map(([k, v]) => [Number(k), v]));
-    case 'map:string':
-      return new Map(Object.entries(json));
-    case 'map:boolean':
-      return new Map(Object.entries(json).map(([k, v]) => [Boolean(k), v]));
-    case 'map:bigint':
-      return new Map(Object.entries(json).map(([k, v]) => [BigInt(k), v]));
+    case 'number':
+      return Number(json);
+    case 'map':
+      return new Map(json);
     case 'set':
       return new Set(json as unknown[]);
     case 'regexp': {
diff --git a/src/types.ts b/src/types.ts
index 0fb61b0..d8f1da8 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -11,16 +11,9 @@ export interface JSONObject {
   [key: string]: JSONValue;
 }
 
-type MapWithUniformKeys =
-  | Map<string, SuperJSONValue>
-  | Map<number, SuperJSONValue>
-  | Map<undefined, SuperJSONValue>
-  | Map<null, SuperJSONValue>
-  | Map<boolean, SuperJSONValue>;
-
 export type SerializableJSONValue =
-  | Set<any>
-  | MapWithUniformKeys
+  | Set<SuperJSONValue>
+  | Map<SuperJSONValue, SuperJSONValue>
   | undefined
   | bigint
   | Date


Test patch:
diff --git a/src/accessDeep.test.ts b/src/accessDeep.test.ts
new file mode 100644
index 0000000..9f5ba5c
--- /dev/null
+++ b/src/accessDeep.test.ts
@@ -0,0 +1,27 @@
+import { setDeep } from './accessDeep';
+
+describe('setDeep', () => {
+  it('correctly sets values in maps', () => {
+    const obj = {
+      a: new Map([['NaN', 10]]),
+    };
+
+    setDeep(obj, ['a', 0, 0], Number);
+
+    expect(obj).toEqual({
+      a: new Map([[NaN, 10]]),
+    });
+  });
+
+  it('correctly sets values in sets', () => {
+    const obj = {
+      a: new Set(['NaN']),
+    };
+
+    setDeep(obj, ['a', 0], Number);
+
+    expect(obj).toEqual({
+      a: new Set([NaN]),
+    });
+  });
+});
diff --git a/src/index.test.ts b/src/index.test.ts
index 959abab..a27a97d 100644
--- a/src/index.test.ts
+++ b/src/index.test.ts
@@ -86,23 +86,20 @@ describe('stringify & parse', () => {
       },
 
       output: {
-        a: {
-          1: 'a',
-          NaN: 'b',
-        },
-        b: {
-          2: 'b',
-        },
-        d: {
-          true: 'true key',
-        },
+        a: [
+          [1, 'a'],
+          ['NaN', 'b'],
+        ],
+        b: [['2', 'b']],
+        d: [[true, 'true key']],
       },
 
       outputAnnotations: {
         values: {
-          a: 'map:number',
-          b: 'map:string',
-          d: 'map:boolean',
+          a: 'map',
+          'a.1.0': 'number',
+          b: 'map',
+          d: 'map',
         },
       },
     },
@@ -203,11 +200,11 @@ describe('stringify & parse', () => {
         a: Number.POSITIVE_INFINITY,
       },
       output: {
-        a: undefined,
+        a: 'Infinity',
       },
       outputAnnotations: {
         values: {
-          a: 'Infinity',
+          a: 'number',
         },
       },
     },
@@ -217,11 +214,11 @@ describe('stringify & parse', () => {
         a: Number.NEGATIVE_INFINITY,
       },
       output: {
-        a: undefined,
+        a: '-Infinity',
       },
       outputAnnotations: {
         values: {
-          a: '-Infinity',
+          a: 'number',
         },
       },
     },
@@ -231,11 +228,11 @@ describe('stringify & parse', () => {
         a: NaN,
       },
       output: {
-        a: undefined,
+        a: 'NaN',
       },
       outputAnnotations: {
         values: {
-          a: 'NaN',
+          a: 'number',
         },
       },
     },
@@ -274,6 +271,118 @@ describe('stringify & parse', () => {
         referentialEqualitiesRoot: ['children.0.parents.0'],
       },
     },
+
+    'works for Maps with two keys that serialize to the same string but have a different reference': {
+      input: new Map([
+        [/a/g, 'foo'],
+        [/a/g, 'bar'],
+      ]),
+      output: [
+        ['/a/g', 'foo'],
+        ['/a/g', 'bar'],
+      ],
+      outputAnnotations: {
+        root: 'map',
+        values: {
+          '0.0': 'regexp',
+          '1.0': 'regexp',
+        },
+      },
+    },
+
+    "works for Maps with a key that's referentially equal to another field": {
+      input: () => {
+        const robbyBubble = { id: 5 };
+        const highscores = new Map([[robbyBubble, 5000]]);
+        return {
+          highscores,
+          topScorer: robbyBubble,
+        } as any;
+      },
+      output: {
+        highscores: [[{ id: 5 }, 5000]],
+        topScorer: { id: 5 },
+      },
+      outputAnnotations: {
+        values: {
+          highscores: 'map',
+        },
+        referentialEqualities: {
+          topScorer: ['highscores.0.0'],
+        },
+      },
+    },
+
+    'works for referentially equal maps': {
+      input: () => {
+        const map = new Map([[1, 1]]);
+        return {
+          a: map,
+          b: map,
+        };
+      },
+      output: {
+        a: [[1, 1]],
+        b: [[1, 1]],
+      },
+      outputAnnotations: {
+        values: {
+          a: 'map',
+          b: 'map',
+        },
+        referentialEqualities: {
+          a: ['b'],
+        },
+      },
+      customExpectations: value => {
+        expect(value.a).toBe(value.b);
+      },
+    },
+
+    'works for maps with non-uniform keys': {
+      input: {
+        map: new Map<string | number, number>([
+          [1, 1],
+          ['1', 1],
+        ]),
+      },
+      output: {
+        map: [
+          [1, 1],
+          ['1', 1],
+        ],
+      },
+      outputAnnotations: {
+        values: {
+          map: 'map',
+        },
+      },
+    },
+
+    'works for referentially equal values inside a set': {
+      input: () => {
+        const user = { id: 2 };
+        return {
+          users: new Set([user]),
+          userOfTheMonth: user,
+        };
+      },
+      output: {
+        users: [{ id: 2 }],
+        userOfTheMonth: { id: 2 },
+      },
+      outputAnnotations: {
+        values: {
+          users: 'set',
+        },
+        referentialEqualities: {
+          userOfTheMonth: ['users.0'],
+        },
+      },
+      customExpectations: value => {
+        expect(value.users.values().next().value).toBe(value.userOfTheMonth);
+      },
+    },
   };
 
   function deepFreeze(object: any, alreadySeenObjects = new Set()) {
diff --git a/src/plainer.test.ts b/src/plainer.test.ts
index 871fcb6..3014f42 100644
--- a/src/plainer.test.ts
+++ b/src/plainer.test.ts
@@ -24,10 +24,10 @@ describe('plainer', () => {
     });
 
     expect(output).toEqual({
-      a: {
-        2: 'hallo',
-        undefined: null,
-      },
+      a: [
+        [2, 'hallo'],
+        [undefined, null],
+      ],
       b: {
         c: [1, 2, /hallo/g],
       },
@@ -60,8 +60,10 @@ describe('plainer', () => {
           2 => "hallo",
           undefined => null,
         },
-        "a.": null,
-        "a.2": "hallo",
+        "a.0.0": 2,
+        "a.0.1": "hallo",
+        "a.1.0": undefined,
+        "a.1.1": null,
         "b": Object {
           "c": Set {
             1,