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);
});
});
2
Answers
It looks like you are encountering a scoping issue with the eval() function when importing your reviver function into another file. The eval() function will execute code within its current scope, which in this case is the scope of the importing file and not of the original file where the Position class was defined.
One possible solution would be to pass the class itself as a parameter to the reviver function instead of relying on the $className key/value pair. For example:
In this example, we pass the Position class as a parameter to the revive() function, and use it to create a new instance of Position instead of using eval(). This way, we avoid the scoping issue that can occur when using eval().
I hope this helps!
Yes. The
class
declaration is scoped to the module, it is not global, which is precisely the advantage of modules. So withPosition
declared in serialization.test.ts, theeval
call in serialization.ts cannot work, asPosition
is not in scope there! You would have needed toimport
it, but of course you wouldn’t want to import all the classes that should be usable in the serialisation library.Instead, pass the classes, keyed by name, as a parameter to the (de)serialisation library, e.g.