Serializing Entities with Decorators in TypeScript

In the process of writing an application with more or less complex business logic on the frontend, there is a need to keep all this logic on the subject area layer in “thick” models. For example, to work with a form that displays the creation or editing of an entity with a large number of interdependent properties on the user interface. If you “smear” the handlers for changing the state of this entity and its subentities across the Application layer, you can easily lose the integrity of the model in different actions, reducers, validators. Such code will be difficult to read, debug, and maintain.

You can use the Aggregate Root pattern for a single entry point for model management, then it will be easier to support the invariant of such an entity. Methods for accessing properties and methods that change the state of the entity can be called from a single object, and the object itself will ensure the integrity and validity of its data. But here another problem appears: serialization. For example, it may be necessary to save the entire entity in some storage – localStorage, redux store. Or send it to the backend for saving. Or update the user interface with an event, and in the event payload, you need to pass a part of the entity in the form of a simple flat object. In these cases, we need an extract of data from the entity, which can be restored later when requested from the storage for further work. This is especially relevant if the project uses SSR, where the data collected for the page on the server side must be serializable.

The serialization problem can be solved “head-on” by adding a serialize method to all classes that are involved in the root entity. It will look something like this:

interface Serializable<T> {
  serialize(): T;
}

enum VehicleType {
  Car="Car",
  Bus="Bus",
  Bike="Bike",
}

type SerializedVehicle = {
  readonly id: string;
  readonly name: string;
  readonly type: VehicleType;
  readonly wheelsNum: number;
};

class Vehicle implements Serializable<SerializedVehicle> {
  constructor(
    public readonly id: string,
    public readonly name: string,
    public readonly type: VehicleType,
    public readonly wheelsNum: number,
  ) {}

  serialize() {
    return {
      id: this.id;
      name: this.name;
      type: this.type;
      wheelsNum: this.wheelsNum;
    }
  }

  drive() {
    // ...do something
  }

  repair() {
    // ...do something
  }
  
  // ...more methods
}

It looks a bit… inconvenient. This is especially depressing with a large number of nested entities. And among them there may also be collections, for which you also need to think through a serialization method. It turns out to be too much boilerplate. Somehow the idea of ​​writing a decorator for this repeating logic arises by itself, so that it would do all this routine work for us. In the end, we want the code to look like this:

@serializable
class Vehicle {
  constructor(
    public readonly id: string,
    public readonly name: string,
    public readonly type: VehicleType,
    public readonly wheelsNum: number,
  ) {
    super();
  }

  drive() {
    // ...do something
  }

  repair() {
    // ...do something
  }
  
  // ...more methods
}

const car = new Vehicle(
  '8247b4f6-13cc-49f3-aac4-e828a2f19c6e',
  'car',
  VehicleType.Car,
  4
);

console.log(car.serialize());

// Expected output:
// {
//   id: '8247b4f6-13cc-49f3-aac4-e828a2f19c6e',
//   name: 'car',
//   type: 'Car',
//   wheelsNum: 4
// }

Such a decorator can use a Proxy object to intercept a call to a serialize() method that is not present in the decorated class. The implementation inside the Proxy will iterate over the properties of the serialized object and collect a serialized extract of data from them.

There are several technical difficulties here:

  1. TypeScript needs to “know” that an object has a serialize() method, and what type it returns;

  2. I would like to exclude some properties from the serialization process and make it flexible;

  3. Iterable objects like collections also need to be serialized into something, such as an array;

  4. Nested entities should be automatically serialized if they also have this decorator.

The first problem can be solved by using an abstract class with a serialize() method that throws an exception if there is no implementation:

abstract class Serializable<T> {
  serialize(): T {
    throw new Error('Method not implemented.');
  }
}

@serializable
class Vehicle extends Serializable<SerializedVehicle> {
  constructor(
    public readonly id: string,
    public readonly name: string,
    public readonly type: VehicleType,
    public readonly wheelsNum: number,
  ) {
    super();
  }

  drive() {
    // ...do something
  }

  repair() {
    // ...do something
  }
}

You can exclude some properties from the serialization result by using additional parameters for the decorator, wrapping it in a function that accepts a variable number of parameters:

function serializable(...propsToExclude: string[]) {
  return function serializableDecorator<T extends { new(...args: any[]): {} }>(SerializableClass: T) {
    return class extends SerializableClass {
      constructor(...args) {
        super(...args);
        
        return new Proxy(this, {
          get(target, prop) {
            if (prop !== 'serialize') {
              return target[prop];
            }
            
            return () => {
              let result: any = {};
              
              Object.keys(target).forEach((key) => {
                if (propsToExclude.includes(key)) {
                  return;
                }
                
                // ...details
              });
            }
          },
        });
      }
    }
  }
}

@serializable('checkExcluded')
class Vehicle extends Serializable<SerializedVehicle> {
  private checkExcluded = 'checkExcluded';

  constructor(
    public readonly id: string,
    public readonly name: string,
    public readonly type: VehicleType,
    public readonly wheelsNum: number,
  ) {
    super();
  }

  drive() {
    // ...do something
  }

  repair() {
    // ...do something
  }
}

To serialize typed collections into an array, you can add code that checks the object for the presence of the Symbol.iterator property:

function serializable(...propsToExclude: string[]) {
  return function serializableDecorator<T extends { new(...args: any[]): {} }>(SerializableClass: T) {
    return class extends SerializableClass {
      constructor(...args) {
        super(...args);
        
        return new Proxy(this, {
          get(target, prop) {
            if (prop !== 'serialize') {
              return target[prop];
            }
            
            return () => {
              let result: any = {};
              
              // iterable object case
              if (typeof target[Symbol.iterator] === 'function'
                && typeof target !== 'string'
              ) {
                result = [];

                for (let value of target as unknown as Iterable<any>) {
                  if (!(value instanceof Serializable)) {
                    continue;
                  }

                  result.push(value.serialize());
                }

                return result;
              }
              
              // ...details
            }
          },
        });
      }
    }
  }
}

abstract class Collection<KEY, VALUE, SERIALIZED> extends Serializable<SERIALIZED[]> {
  protected data: Map<KEY, VALUE>;

  protected constructor() {
    super();
  }

  *[Symbol.iterator]() {
    for (const [,item] of this.data) {
      yield item;
    }
  }
}

@serializable()
class VehicleCollection extends Collection<Vehicle['id'], Vehicle, SerializedVehicle[]> {
  constructor(vehicles: Vehicle[]) {
    super();

    this.data = new Map(vehicles.map((vehicle) => [vehicle.id, vehicle]));
  }
}

Nested entities can be serialized by inheriting from the Serializable class. The full decorator code:

Hidden text
import { Serializable } from './Serializable';

export function serializable(...propsToExclude: string[]) {
  return function serializableDecorator<T extends { new(...args: any[]): {} }>(SerializableClass: T) {
    return class extends SerializableClass {
      constructor(...args) {
        super(...args);

        return new Proxy(this, {
          get(target, prop) {
            if (prop !== 'serialize') {
              return target[prop];
            }

            return () => {
              let result: any = {};

              // iterable object case
              if (typeof target[Symbol.iterator] === 'function'
                && typeof target !== 'string'
              ) {
                result = [];

                for (let value of target as unknown as Iterable<any>) {
                  if (!(value instanceof Serializable)) {
                    continue;
                  }

                  result.push(value.serialize());
                }

                return result;
              }

              Object.keys(target).forEach((key) => {
                if (typeof target[key] === 'function'
                  || propsToExclude.includes(key)
                  || (typeof target[key] === 'object'
                    && target[key] !== null
                    && !(target[key] instanceof Serializable))
                ) {
                  return;
                }

                if (typeof target[key] === 'object'
                  && target[key] !== null
                  && typeof target[key][Symbol.iterator] === 'function'
                  && typeof target[key] !== 'string'
                ) {
                  result[key] = [];

                  for (let value of target[key]) {
                    if (!(value instanceof Serializable)) {
                      continue;
                    }

                    result[key].push(value.serialize());
                  }

                  return;
                }

                if (target[key] instanceof Serializable) {
                  result[key] = target[key].serialize();

                  return;
                }

                result[key] = target[key];
              })

              return result;
            };
          }
        });
      }
    };
  }
}

Test entities to check:

Hidden text
import { serializable } from './serializableDecorator';
import { Serializable } from './Serializable';

export enum VehicleType {
  Car="Car",
  Bus="Bus",
  Bike="Bike",
}

type SerializedVehicle = {
  readonly id: string;
  readonly name: string;
  readonly type: VehicleType;
  readonly wheelsNum: number;
};

@serializable('checkExcluded')
export class Vehicle extends Serializable<SerializedVehicle> {
  private checkExcluded = 'checkExcluded';
  private checkNotSerializable = Object.create({});

  constructor(
    public readonly id: string,
    public readonly name: string,
    public readonly type: VehicleType,
    public readonly wheelsNum: number,
  ) {
    super();
  }

  get checkGetter() {
    return 'test';
  }

  drive() {
    // ...do something
  }

  repair() {
    // ...do something
  }
}

abstract class Collection<KEY, VALUE, SERIALIZED> extends Serializable<SERIALIZED[]> {
  protected data: Map<KEY, VALUE>;

  protected constructor() {
    super();
  }

  *[Symbol.iterator]() {
    for (const [,item] of this.data) {
      yield item;
    }
  }
}

@serializable()
export class VehicleCollection extends Collection<Vehicle['id'], Vehicle, SerializedVehicle[]> {
  constructor(vehicles: Vehicle[]) {
    super();

    this.data = new Map(vehicles.map((vehicle) => [vehicle.id, vehicle]));
  }
}

type SerializableStreet = {
  readonly id: string;
  readonly name: string;
  readonly vehicles: SerializedVehicle[],
}

// Example with nested serializable collection and object
@serializable()
export class Street extends Serializable<SerializableStreet> {
  constructor(
    public readonly id: string,
    public readonly name: string,
    public readonly vehicles: VehicleCollection,
    public readonly firstVehicle: Vehicle,
  ) {
    super();
  }
}

Test code (you could write tests, but perhaps it would be more clear):

Hidden text
import {Street, Vehicle, VehicleCollection, VehicleType} from './Vehicle';

const car = new Vehicle(
  '8247b4f6-13cc-49f3-aac4-e828a2f19c6e',
  'car',
  VehicleType.Car,
  4
);

console.log(car.serialize());

// Expected output:
// {
//   id: '8247b4f6-13cc-49f3-aac4-e828a2f19c6e',
//   name: 'car',
//   type: 'Car',
//   wheelsNum: 4
// }

const collection = new VehicleCollection([
  new Vehicle(
    '8247b4f6-13cc-49f3-aac4-e828a2f19c6e',
    'car',
    VehicleType.Car,
    4
  ),
  new Vehicle(
    '229ade70-d5cd-4841-a60f-ec8ddf141780',
    'bus',
    VehicleType.Bus,
    8
  ),
  new Vehicle(
    '96587162-9410-48b9-a5c6-89209ed4685c',
    'bike',
    VehicleType.Bike,
    2
  )
]);

console.log(collection.serialize());

// Expected output:
// [
//   {
//     id: '8247b4f6-13cc-49f3-aac4-e828a2f19c6e',
//     name: 'car',
//     type: 'Car',
//     wheelsNum: 4
//   },
//   {
//     id: '229ade70-d5cd-4841-a60f-ec8ddf141780',
//     name: 'bus',
//     type: 'Bus',
//     wheelsNum: 8
//   },
//   {
//     id: '96587162-9410-48b9-a5c6-89209ed4685c',
//     name: 'bike',
//     type: 'Bike',
//     wheelsNum: 2
//   }
// ]


const street = new Street(
  '8247b4f6-13cc-49f3-aac4-e828a2f19c6e',
  'Street Name',
  collection,
  new Vehicle(
    'ed0c0b19-9d54-42e5-b8d3-a4c0b1760781',
    'bike',
    VehicleType.Bike,
    2
  )
);

console.log(street.serialize());

// Expected output:
// {
//   id: '8247b4f6-13cc-49f3-aac4-e828a2f19c6e',
//   name: 'Street Name',
//   vehicles: [
//     {
//       id: '8247b4f6-13cc-49f3-aac4-e828a2f19c6e',
//       name: 'car',
//       type: 'Car',
//       wheelsNum: 4
//     },
//     {
//       id: '229ade70-d5cd-4841-a60f-ec8ddf141780',
//       name: 'bus',
//       type: 'Bus',
//       wheelsNum: 8
//     },
//     {
//       id: '96587162-9410-48b9-a5c6-89209ed4685c',
//       name: 'bike',
//       type: 'Bike',
//       wheelsNum: 2
//     }
//   ],
//   firstVehicle: {
//     id: 'ed0c0b19-9d54-42e5-b8d3-a4c0b1760781',
//       name: 'bike',
//       type: 'Bike',
//       wheelsNum: 2
//   }
// }

As a result, there are several problems that we can still think about solving:

  1. Control over the type of the serialized object remains with the developer;

  2. Iterable objects are serialized into an array, the remaining properties are ignored, this is how you can serialize typed collections, but for objects for which you need to serialize all properties together with the iterator, you will have to think through additional logic;

  3. The decorator implementation looks quite complex, there is probably room for refactoring;

  4. Most likely, there are many unaccounted cases, they can be worked through if necessary;

  5. The solution with an abstract class for typing is confusing; perhaps typing can also be implemented through a decorator, but I don’t see how off the top of my head.

I think if you search carefully, you will find ready-made solutions to this problem. The approach in the article is not ideal, but as a basis it may be useful to someone if there are reasons not to use third-party libraries or the need to write code for a special case.

Code on github: https://github.com/BoesesGenie/ts-serializable-decorator

What to read on the topic:

  1. About TypeScript decorators: https://devblogs.microsoft.com/typescript/announcing-typescript-5-0/#decorators

  2. About the Proxy object:

  • https://developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Global_Objects/Proxy

  • Flanagan, David. JavaScript. The Complete Guide, 7th ed. Chapter 14. Metaprogramming

  • Zakas, Nicholas. ECMAScript 6 for Developers. Chapter 12. Proxy Objects and the Reflection API.

P.S. The noir detective code casts a shadow that decorates its noirness.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *