Repository: blitz-js/superjson (ts)

Issue:
Add support for preserving Symbols
Similarly to how #36 adds support for preserving class instances across SuperJSON, we also can add support for preserving Symbols using something like `SuperJSON.registerSymbol(MySymbol)`.

Pull request description:
Add support for reviving symbols
Closes #45, depends on #36

Similarly to `SuperJSON.registerClass` introduced in #36, this PR adds support for reviving symbols using a Symbol registry: `SuperJSON.registerSymbol`.

Patch:
diff --git a/src/class-registry.ts b/src/class-registry.ts
index 0ed301c..1f12fd0 100644
--- a/src/class-registry.ts
+++ b/src/class-registry.ts
@@ -1,67 +1,4 @@
+import { Registry } from './registry';
 import { Class } from './types';
 
-class DoubleIndexedKV<K, V> {
-  keyToValue = new Map<K, V>();
-  valueToKey = new Map<V, K>();
-
-  set(key: K, value: V) {
-    this.keyToValue.set(key, value);
-    this.valueToKey.set(value, key);
-  }
-
-  deleteByValue(value: V) {
-    this.valueToKey.delete(value);
-    this.keyToValue.forEach((otherValue, otherKey) => {
-      if (value === otherValue) {
-        this.keyToValue.delete(otherKey);
-      }
-    });
-  }
-
-  getByKey(key: K): V | undefined {
-    return this.keyToValue.get(key);
-  }
-
-  getByValue(value: V): K | undefined {
-    return this.valueToKey.get(value);
-  }
-
-  clear() {
-    this.keyToValue.clear();
-    this.valueToKey.clear();
-  }
-}
-
-const classRegistry = new DoubleIndexedKV<string, Class>();
-
-export function registerClass(clazz: Class, identifier?: string): void {
-  if (classRegistry.getByValue(clazz)) {
-    return;
-  }
-
-  if (!identifier) {
-    identifier = clazz.name;
-  }
-
-  if (classRegistry.getByKey(identifier)) {
-    throw new Error('Ambiguous class, provide a unique identifier.');
-  }
-
-  classRegistry.set(identifier, clazz);
-}
-
-export function unregisterClass(clazz: Class): void {
-  classRegistry.deleteByValue(clazz);
-}
-
-export function clear(): void {
-  classRegistry.clear();
-}
-
-export function getIdentifier(clazz: Class) {
-  return classRegistry.getByValue(clazz);
-}
-
-export function getClass(identifier: string) {
-  return classRegistry.getByKey(identifier);
-}
+export const ClassRegistry = new Registry<Class>(c => c.name);
diff --git a/src/double-indexed-kv.ts b/src/double-indexed-kv.ts
new file mode 100644
index 0000000..9e348f0
--- /dev/null
+++ b/src/double-indexed-kv.ts
@@ -0,0 +1,31 @@
+export class DoubleIndexedKV<K, V> {
+  keyToValue = new Map<K, V>();
+  valueToKey = new Map<V, K>();
+
+  set(key: K, value: V) {
+    this.keyToValue.set(key, value);
+    this.valueToKey.set(value, key);
+  }
+
+  deleteByValue(value: V) {
+    this.valueToKey.delete(value);
+    this.keyToValue.forEach((otherValue, otherKey) => {
+      if (value === otherValue) {
+        this.keyToValue.delete(otherKey);
+      }
+    });
+  }
+
+  getByKey(key: K): V | undefined {
+    return this.keyToValue.get(key);
+  }
+
+  getByValue(value: V): K | undefined {
+    return this.valueToKey.get(value);
+  }
+
+  clear() {
+    this.keyToValue.clear();
+    this.valueToKey.clear();
+  }
+}
diff --git a/src/index.ts b/src/index.ts
index 2364ca3..5b7e522 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,8 +1,14 @@
 import { applyAnnotations, makeAnnotator } from './annotator';
 import { isEmptyObject } from './is';
 import { plainer } from './plainer';
-import { SuperJSONResult, SuperJSONValue, isSuperJSONResult } from './types';
-import { clear, registerClass, unregisterClass } from './class-registry';
+import {
+  SuperJSONResult,
+  SuperJSONValue,
+  isSuperJSONResult,
+  Class,
+} from './types';
+import { ClassRegistry } from './class-registry';
+import { SymbolRegistry } from './symbol-registry';
 
 const serialize = (object: SuperJSONValue): SuperJSONResult => {
   const { getAnnotations, annotator } = makeAnnotator();
@@ -38,12 +44,21 @@ const stringify = (object: SuperJSONValue): string =>
 export const parse = <T = unknown>(string: string): T =>
   deserialize(JSON.parse(string));
 
+const registerClass = (v: Class, identifier?: string) =>
+  ClassRegistry.register(v, identifier);
+const unregisterClass = (v: Class) => ClassRegistry.unregister(v);
+
+const registerSymbol = (v: Symbol, identifier?: string) =>
+  SymbolRegistry.register(v, identifier);
+const unregisterSymbol = (v: Symbol) => SymbolRegistry.unregister(v);
+
 export default {
   stringify,
   parse,
   serialize,
   deserialize,
-  clear,
   registerClass,
   unregisterClass,
+  registerSymbol,
+  unregisterSymbol,
 };
diff --git a/src/registry.ts b/src/registry.ts
new file mode 100644
index 0000000..99e476f
--- /dev/null
+++ b/src/registry.ts
@@ -0,0 +1,39 @@
+import { DoubleIndexedKV } from './double-indexed-kv';
+
+export class Registry<T> {
+  private kv = new DoubleIndexedKV<string, T>();
+
+  constructor(private readonly generateIdentifier: (v: T) => string) {}
+
+  register(value: T, identifier?: string): void {
+    if (this.kv.getByValue(value)) {
+      return;
+    }
+
+    if (!identifier) {
+      identifier = this.generateIdentifier(value);
+    }
+
+    if (this.kv.getByKey(identifier)) {
+      throw new Error('Ambiguous class, provide a unique identifier.');
+    }
+
+    this.kv.set(identifier, value);
+  }
+
+  unregister(v: T): void {
+    this.kv.deleteByValue(v);
+  }
+
+  clear(): void {
+    this.kv.clear();
+  }
+
+  getIdentifier(value: T) {
+    return this.kv.getByValue(value);
+  }
+
+  getValue(identifier: string) {
+    return this.kv.getByKey(identifier);
+  }
+}
diff --git a/src/symbol-registry.ts b/src/symbol-registry.ts
new file mode 100644
index 0000000..058683d
--- /dev/null
+++ b/src/symbol-registry.ts
@@ -0,0 +1,3 @@
+import { Registry } from './registry';
+
+export const SymbolRegistry = new Registry<Symbol>(s => s.description ?? '');
diff --git a/src/transformer.ts b/src/transformer.ts
index 4517520..db31c4e 100644
--- a/src/transformer.ts
+++ b/src/transformer.ts
@@ -7,8 +7,10 @@ import {
   isRegExp,
   isSet,
   isUndefined,
+  isSymbol,
 } from './is';
-import * as ClassRegistry from './class-registry';
+import { ClassRegistry } from './class-registry';
+import { SymbolRegistry } from './symbol-registry';
 import * as IteratorUtils from './iteratorutils';
 
 export type PrimitiveTypeAnnotation = 'number' | 'undefined' | 'bigint';
@@ -16,8 +18,13 @@ export type PrimitiveTypeAnnotation = 'number' | 'undefined' | 'bigint';
 type LeafTypeAnnotation = PrimitiveTypeAnnotation | 'regexp' | 'Date';
 
 type ClassTypeAnnotation = ['class', string];
+type SymbolTypeAnnotation = ['symbol', string];
 
-type ContainerTypeAnnotation = 'map' | 'set' | ClassTypeAnnotation;
+type ContainerTypeAnnotation =
+  | 'map'
+  | 'set'
+  | ClassTypeAnnotation
+  | SymbolTypeAnnotation;
 
 export type TypeAnnotation = LeafTypeAnnotation | ContainerTypeAnnotation;
 
@@ -42,6 +49,7 @@ export const isTypeAnnotation = (value: any): value is TypeAnnotation => {
     switch (value[0]) {
       case 'map':
         return ['number', 'string', 'bigint', 'boolean'].includes(value[1]);
+      case 'symbol':
       case 'class':
         return typeof value[1] === 'string';
     }
@@ -58,6 +66,14 @@ export const transformValue = (
       value: undefined,
       type: 'undefined',
     };
+  } else if (isSymbol(value)) {
+    const identifier = SymbolRegistry.getIdentifier(value);
+    if (identifier) {
+      return {
+        value: value.description,
+        type: ['symbol', identifier],
+      };
+    }
   } else if (isBigint(value)) {
     return {
       value: value.toString(),
@@ -113,7 +129,7 @@ export const untransformValue = (json: any, type: TypeAnnotation) => {
   if (Array.isArray(type)) {
     switch (type[0]) {
       case 'class': {
-        const clazz = ClassRegistry.getClass(type[1]);
+        const clazz = ClassRegistry.getValue(type[1]);
 
         if (!clazz) {
           throw new Error('Trying to deserialize unknown class');
@@ -121,6 +137,16 @@ export const untransformValue = (json: any, type: TypeAnnotation) => {
 
         return Object.assign(Object.create(clazz.prototype), json);
       }
+
+      case 'symbol': {
+        const symbol = SymbolRegistry.getValue(type[1]);
+
+        if (!symbol) {
+          throw new Error('Trying to deserialize unknown symbol');
+        }
+
+        return symbol;
+      }
     }
   }
 
diff --git a/src/types.ts b/src/types.ts
index 647222e..a13bc69 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -16,6 +16,7 @@ export interface JSONObject {
 type ClassInstance = any;
 
 export type SerializableJSONValue =
+  | Symbol
   | Set<SuperJSONValue>
   | Map<SuperJSONValue, SuperJSONValue>
   | undefined


Test patch:
diff --git a/src/class-registry.test.ts b/src/class-registry.test.ts
deleted file mode 100644
index 4e6e410..0000000
--- a/src/class-registry.test.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import * as ClassRegistry from './class-registry';
-
-test('class registry', () => {
-  class Car {
-    honk() {
-      console.log('honk');
-    }
-  }
-  ClassRegistry.registerClass(Car);
-
-  expect(ClassRegistry.getClass('Car')).toBe(Car);
-  expect(ClassRegistry.getIdentifier(Car)).toBe('Car');
-
-  expect(() => ClassRegistry.registerClass(Car)).not.toThrow();
-
-  expect(() => ClassRegistry.registerClass(class Car {})).toThrow(
-    'Ambiguous class, provide a unique identifier.'
-  );
-
-  ClassRegistry.unregisterClass(Car);
-
-  expect(ClassRegistry.getClass('Car')).toBeUndefined();
-
-  ClassRegistry.registerClass(Car, 'car1');
-
-  ClassRegistry.registerClass(class Car {}, 'car2');
-
-  expect(ClassRegistry.getClass('car1')).toBe(Car);
-
-  expect(ClassRegistry.getClass('car2')).not.toBeUndefined();
-
-  ClassRegistry.clear();
-
-  expect(ClassRegistry.getClass('car1')).toBeUndefined();
-});
diff --git a/src/index.test.ts b/src/index.test.ts
index 4bfb47f..d9bb14f 100644
--- a/src/index.test.ts
+++ b/src/index.test.ts
@@ -380,6 +380,30 @@ describe('stringify & parse', () => {
         expect(value.users.values().next().value).toBe(value.userOfTheMonth);
       },
     },
+
+    'works for symbols': {
+      input: () => {
+        const parent = Symbol('Parent');
+        const child = Symbol('Child');
+        SuperJSON.registerSymbol(parent, '1');
+        SuperJSON.registerSymbol(child, '2');
+
+        const a = { role: parent };
+        const b = { role: child };
+
+        return { a, b };
+      },
+      output: {
+        a: { role: 'Parent' },
+        b: { role: 'Child' },
+      },
+      outputAnnotations: {
+        values: {
+          'a.role': [['symbol', '1']],
+          'b.role': [['symbol', '2']],
+        },
+      },
+    },
   };
 
   function deepFreeze(object: any, alreadySeenObjects = new Set()) {
@@ -457,7 +481,7 @@ describe('stringify & parse', () => {
       SuperJSON.registerClass(Train);
 
       const { json, meta } = SuperJSON.serialize({
-        s7: new Train(100, 'yellow', 'Bombardier'),
+        s7: new Train(100, 'yellow', 'Bombardier') as any,
       });
 
       expect(json).toEqual({
@@ -494,7 +518,7 @@ describe('stringify & parse', () => {
         SuperJSON.registerClass(Currency);
 
         const { json, meta } = SuperJSON.serialize({
-          price: new Currency(100),
+          price: new Currency(100) as any,
         });
 
         expect(json).toEqual({
diff --git a/src/registry.test.ts b/src/registry.test.ts
new file mode 100644
index 0000000..3c8de46
--- /dev/null
+++ b/src/registry.test.ts
@@ -0,0 +1,38 @@
+import { Registry } from './registry';
+import { Class } from './types';
+
+test('class registry', () => {
+  const registry = new Registry<Class>(c => c.name);
+
+  class Car {
+    honk() {
+      console.log('honk');
+    }
+  }
+  registry.register(Car);
+
+  expect(registry.getValue('Car')).toBe(Car);
+  expect(registry.getIdentifier(Car)).toBe('Car');
+
+  expect(() => registry.register(Car)).not.toThrow();
+
+  expect(() => registry.register(class Car {})).toThrow(
+    'Ambiguous class, provide a unique identifier.'
+  );
+
+  registry.unregister(Car);
+
+  expect(registry.getValue('Car')).toBeUndefined();
+
+  registry.register(Car, 'car1');
+
+  registry.register(class Car {}, 'car2');
+
+  expect(registry.getValue('car1')).toBe(Car);
+
+  expect(registry.getValue('car2')).not.toBeUndefined();
+
+  registry.clear();
+
+  expect(registry.getValue('car1')).toBeUndefined();
+});