В процессе написания приложения с более-менее сложной бизнес-логикой на фронтенде возникает необходимость держать всю эту логику на слое предметной области в "толстых" моделях. Например, для работы с формой, которая отображает на пользовательский интерфейс создание или редактирование сущности с большим количеством взаимозависимых свойств. Если "размазать" обработчики изменения состояния этой сущности и входящих в неё подсущностей по слою Application, легко можно потерять целостность модели в разных actions, reducers, валидаторах. Такой код будет трудно читать, отлаживать и поддерживать.

Можно использовать паттерн Aggregate Root для единой точки входа управления моделью, тогда упростится поддержка инварианта такой сущности. Методы доступа к свойствам и методы, меняющие состояние сущности, можно вызывать из одного объекта, а сам объект будет обеспечивать целостность и валидность своих данных. Но здесь появляется ещё одна проблема: сериализация. К примеру, бывает нужно сохранить всю сущность в каком-нибудь хранилище -- localStorage, redux store. Или отправить на бэкэнд для сохранения. Или событием обновить пользовательский интерфейс, а в payload события при этом надо передать часть сущности в виде простого плоского объекта. В этих случаях нам нужна выжимка данных из сущности, которую можно будет восстановить потом при запросе из хранилища для дальнейшей работы. Это особенно актуально, если на проекте используется SSR, там данные, которые собираются для страницы на серверной стороне, должны быть сериализуемыми.

Проблему сериализации можно решить "в лоб", добавив во все классы, которые задействованы в корневой сущности, метод serialize. Это будет выглядеть примерно так:

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
}

Выглядит несколько... неудобно. Особенно удручает это при большом количестве вложенных сущностей. А среди них могут быть ещё и коллекции, для которых тоже нужно продумать способ сериализации. Получается слишком много бойлерплейта. Как-то сама собой возникает идея написать для этой повторяющейся логики декоратор, чтобы он всю эту рутинную работу делал за нас. В итоге хочется, чтобы код выглядел так:

@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
// }

Такой декоратор может использовать объект Proxy для перехвата обращения к методу serialize(), которого нет в декорируемом классе. Реализация внутри Proxy будет пробегаться по свойствам сериализуемого объекта и собирать из них сериализованную выжимку данных.

Тут есть несколько технических сложностей:

  1. Нужно, чтобы TypeScript "знал", что у объекта есть метод serialize(), а также какой тип он возвращает;

  2. Какие-то свойства хотелось бы исключить из процесса сериализации и сделать это гибко;

  3. Итерируемые объекты вроде коллекций надо тоже во что-то сериализовать, например, в массив;

  4. Вложенные сущности должны автоматически сериализоваться, если у них тоже есть этот декоратор.

Первую проблему можно решить с помощью абстрактного класса с методом serialize(), который бросает исключение, если нет реализации:

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
  }
}

Исключить из результата сериализации некоторые свойства можно с помощью дополнительных параметров для декоратора, обернув его в функцию, которая принимает список параметров переменного количества:

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
  }
}

Для сериализации типизированных коллекций в массив можно добавить код, который проверяет объект на наличие свойства Symbol.iterator:

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]));
  }
}

Вложенные сущности можно сериализовать по условию наследования от класса Serializable. Proxy можно заменить на Object.assign(), что позволит сделать код лаконичнее, как подсказали в комментариях (пользователь @garbagecollected). Полный код декоратора:

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 Object.assign(this, {
          serialize() {
            // iterable object case
            if (typeof this[Symbol.iterator] === 'function') {
              const result = [];

              for (let value of this) {
                if (!(value instanceof Serializable)) {
                  continue;
                }

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

              return result;
            }

            const result = {};

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

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

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

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

                return;
              }

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

                return;
              }

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

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

Тестовые сущности для проверки:

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();
  }
}

Проверочный код (можно было написать тесты, но, возможно, так нагляднее):

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
//   }
// }

В итоге остаётся несколько проблем, над решением которых можно ещё подумать:

  1. Контроль над типом сериализованного объекта остаётся на совести разработчика;

  2. Итерируемые объекты сериализуются в массив, остальные свойства игноринуются, так можно сериализовать типизированные коллекции, но для объектов, для которых нужно сериализовать все свойства вместе с итератором придётся продумать дополнительную логику;

  3. Реализация декоратора выглядит довольно сложно, вероятно, есть пространство для рефакторинга;

  4. Скорее всего, есть много неучтённых кейсов, их можно проработать при необходимости;

  5. Смущает решение с абстрактным классом для типизации, возможно, типизацию можно реализовать тоже через декоратор, но навскидку я не вижу как.

Думаю, если тщательно поискать, найдутся готовые решения этой задачи. В статье подход не идеальный, но в качестве основы может кому-нибудь пригодиться, если есть причины не использоваться сторонние библиотеки или необходимость написать код под особый случай.

Код на github: https://github.com/BoesesGenie/ts-serializable-decorator

Что почитать по теме:

  1. Про декораторы:

    • TypeScript: https://devblogs.microsoft.com/typescript/announcing-typescript-5-0/#decorators (в статье используются legacy декораторы)

    • TC39 в процессе внедрения: https://github.com/tc39/proposal-decorators (в статье не используются)

  2. Про объект Proxy:

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

    • Флэнаган, Дэвид. JavaScript.Полное руководство, 7-е изд. Глава 14. Метапрограммирование

    • Закас, Николас. ECMAScript 6 для разработчиков. Глава 12. Прокси-объекты и Reflection API

PS. Нуарный кот-детектив отбрасывает тень, которая декорирует его нуарность.

Комментарии (10)


  1. fransua
    17.08.2024 19:09

    А можно эту логику поместить в базовый класс Serializable и в нем пробегать по ключам this?


    1. Boeses_Genie Автор
      17.08.2024 19:09

      Думаю, можно, но тогда потеряется гибкость с возможностью исключения свойств, которые не должны попадать в сериализованный объект. Вообще мне идея с абстрактным классом чисто для типизации не очень нравится. Подумаю как-нибудь над решением без него. Если получится, обновлю материал.


      1. fransua
        17.08.2024 19:09
        +3

        Чтобы исключать свойства можно их декорировать, еще можно кастомный сериализатор добавлять:

        class Vehicle extends Serializable<SerializedVehicle>
        {
           @notSerialize()
           private field: string;
        
           @serialize(x => x.toISOString())
           private createdAt: Date;
        }
        

        Кстати, какие декораторы используете, legacy или из TC39? С новыми периодически проблемы, что они не везде поддерживаются, или поддерживаются но по-разному, а старые рано или поздно выпилят.


        1. Boeses_Genie Автор
          17.08.2024 19:09

          Тут legacy, пока не выпилили. На TC39 не слишком трудно переделать будет. По поводу декорирования свойств видел решение, показалось труднее читать будет, искать по коду, плюс больше писанины. Хотя, это дело вкуса. В принципе, если над универсальным решением задуматься, можно и с декорированием свойств сделать, и с декорированием класса. С прокидыванием кастомного сериализатора хорошая мысль.


          1. ganqqwerty
            17.08.2024 19:09
            +3

            Мне вариант с декорированием отдельных пропертей нравится тем, что тогда эти проперти не будут строками писаться в параметрах большого декоратора. А то ведь, я переименую свойство, а надо помнить, что в параметрах декоратора тоже надо его переименовать.


  1. garbagecollected
    17.08.2024 19:09
    +4

    Исключить из результата сериализации некоторые свойства можно с помощью дополнительных параметров для декоратора, обернув его в функцию, которая принимает список параметров переменного количества

    А вы в курсе, что нативная реализация JSON.stringify() вторым параметром принимает replacer, который может быть callback, исключающий свойства из объекта? Ваша реализация вместе с Proxy() выглядит куда более нагроможденной. Вместо Proxy() можно использовать Object.assign({}, object), а затем, используя delete, явно удалить ключи, которые не должны попадать в сериализацию. Это было бы нагляднее, понятнее и визуально короче - но это слишком "по-яваскриптовски".

    А так, на первый взгляд, если не мучать себя ограничениями TS, весь код статьи можно оформить как небольшой однострочник использованияJSON.stringify().


    1. Boeses_Genie Автор
      17.08.2024 19:09

      Вообще, Proxy нужен, чтобы ловить обращение к методу serialize(), который отсутствует в исходном объекте. Пока не могу понять, как тут Object.assign() поможет. Создавать из исходного новый объект с методом serialize(), а в методе уже опять использовать Object.assign(), чтобы скопировать исходный объект и удалить "лишние" ключи? С JSON.stringify() интересная мысль. Но на выходе надо ещё JSON.parse() сделать, плюс опять же с коллекциями разобраться, так что не выйдет однострочник, наверное. Попробую упростить с этим, если решение проще будет рабочее, выложу.


    1. Boeses_Genie Автор
      17.08.2024 19:09
      +1

      Если заменить Proxy на Object.assign(), код получается лаконичнее, спасибо, доработал в статье. Но использовать Object.assign() для самой сериализции -- не очень идея, по-моему. Нам же нужно исключить ещё и функции, вычисляемые свойства тоже может потребоваться не сериализовать, так что там просто с delete выйдет та же сложность. Про JSON.stringify() -- будут получаться некорректные данные на выходе в случае undefined и всяких NaN, Infinity. Вот тут подробности: https://medium.com/@pmzubar/why-json-parse-json-stringify-is-a-bad-practice-to-clone-an-object-in-javascript-b28ac5e36521 .


  1. jbourne
    17.08.2024 19:09

    @Boeses_Genie

    Из того, что использовал, может пригодится:

    1. Сериализация в JSON (похоже на Java Jackson): typestack/class-transformer

    2. Валидация типа (схемы) сериализованного JSON: zod

    Оба инструмента работают и задачу выполняют. В обоих нужно добавлять минимум кода для работы фреймворка. Оба довольно гибки.

    Он они подходят не всегда. Есть и минусы:

    Zod - требует описывать тип объекта на "своем языке", который используется для валидации и вывода TypeScript типа.

    class-transformer'у нужно проставить декораторы в классе сущности.


    1. Boeses_Genie Автор
      17.08.2024 19:09

      Спасибо, посмотрю.