I'm trying to write a simple replacer/reviver combination that lets me serialize and deserialize ES6 classes properly (I need to deserialize them as ES6 classes, not Object
s) for a TypeScript project I'm working on.
I got it working easily enough by storing the class names in the JSON output under a special key/value pair (i.e. "$className":"Position"
).
The only problem is that the only way I could find to instantiate these classes later was to use:
const instance = eval(`new ${className}()`);
I was writing the rough draft for the code in a test file using Vitest to make sure it worked, and everything was working great until I was done and moved it to another file. If I import the reviver function that uses eval()
under the hood, it gives me the following error:
ReferenceError: Position is not defined
Note: Position
is an ES6 class in my test file
If I copy/paste the reviver code back in the .test.ts
file, the tests pass. If I remove that code and use import
, it complains about Position
not being defined.
I'm kind of confused about what might be causing this. Is it a known limitation of modules? Is it a TypeScript or Vite thing where the file imports mess with the hoisting, maybe? Is it an issue with eval()
when imported?
Any help would be appreciated.
Edit: Added full code below. Moving the contents of serialization.ts
before or after the class declarations in serialization.test.ts
doesn't cause the ReferenceError: Position is not defined
error.
serialization.ts
type JsonCallback = (key: string, value: any) => any;
export const replacerWithMap = (): [replacer: JsonCallback, idsToClassNames: Map<number, string>] => {
let counter = 0;
const classNamesToIds = new Map<string, number>();
const idsToClassNames = new Map<number, string>();
const replacer = (_: string, value: any): any => {
if (typeof value === 'object' && !Array.isArray(value) && !!value.constructor) {
const className = value.constructor.name;
if (!className) {
throw new Error(`Expected value to be class instance but was: ${value}`);
}
if (Object.hasOwn(value, '$_')) {
throw new Error(`Illegal property name "$_" found on object during serialization: ${className}`);
}
let id = classNamesToIds.get(className);
if (!id) {
id = counter;
classNamesToIds.set(className, id);
idsToClassNames.set(id, className);
++counter;
}
return {
'$_': id,
...value
};
}
return value;
}
return [replacer, idsToClassNames];
}
export const reviverFromMap = (idsToClassNames: Map<number, string>): JsonCallback => {
const reviver = (_: string, value: any): any => {
if (typeof value === 'object') {
if (Object.hasOwn(value, '$_')) {
const className = idsToClassNames.get(value.$_);
const instance = eval(`new ${className}()`); // <-------- eval() here
for (const [key, val] of Object.entries(value)) {
if (key !== '$_') {
instance[key] = val;
}
}
return instance;
}
}
return value;
}
return reviver;
}
serialization.test.ts
import { describe, expect, it } from 'vitest';
import { replacerWithMap, reviverFromMap } from './serialization';
class Position {
public x: number;
public y: number;
private _z: number;
public constructor(x: number, y: number) {
this.x = x;
this.y = y;
this._z = x * y;
}
public get z(): number {
return this._z;
}
public set z(value: number) {
this._z = value;
}
}
class Transform {
// Testing to see if it handles nested instances properly.
public position = new Position(3, 5);
public name: string = '';
}
describe('Serialization helpers', () => {
it('Serializes objects and maintains their class names in a map', () => {
// Arrange
const transform = new Transform();
transform.name = 'rotation';
// Act
const [replacer, idsToClassNames] = replacerWithMap();
const reviver = reviverFromMap(idsToClassNames);
const str = JSON.stringify(transform, replacer);
console.log(str, idsToClassNames);
const deserialized = JSON.parse(str, reviver) as Transform;
// Assert
expect(deserialized).toEqual(transform);
expect(deserialized).toBeInstanceOf(Transform);
expect(deserialized.position).toEqual(transform.position);
expect(deserialized.position).toBeInstanceOf(Position);
expect(deserialized.position.x).toBe(3);
expect(deserialized.position.y).toBe(5);
expect(deserialized.position.z).toBe(deserialized.position.y * deserialized.position.x);
});
});