skip to Main Content

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 Objects) 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


  1. 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:

    class Position {
      constructor(x, y) {
        this.x = x;
        this.y = y;
      }
    }
    
    function serialize(obj) {
      return JSON.stringify(obj);
    }
    
    function revive(key, value, clazz) {
      if (typeof value === 'object' && value !== null && '$className' in value) {
        const className = value.$className;
        delete value.$className;
        return Object.assign(new clazz(), value);
      }
      return value;
    }
    
    const obj = new Position(1, 2);
    const serialized = serialize(obj);
    
    const deserialized = JSON.parse(serialized, (key, value) => revive(key, value, Position));
    console.log(deserialized instanceof Position); // true
    

    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!

    Login or Signup to reply.
  2. I’m kind of confused about what might be causing this. Is it a known limitation of modules?

    Yes. The class declaration is scoped to the module, it is not global, which is precisely the advantage of modules. So with Position declared in serialization.test.ts, the eval call in serialization.ts cannot work, as Position is not in scope there! You would have needed to import 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.

    const classes = new Map(Object.entries({
      Position,
      Transform,
    }));
    const {replacer, reviver} = serialisation(classes);
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search