Привет, Хабр! Хочу поделиться с вами своей библиотекой для десериализации объектов JSON в классы, которая еще и автоматически валидирует по типам входные данные.

Не так давно в JavaScript появилась такая замечательная вещь как классы, которая значительно упростила процесс написания кода. Но к сожалению не появился функционал для десериализации JSON в эти самые классы, т.е. сериализовать класс в строку можно, а вот обратно уже своими силами. И вот для исправления этого недостатка и была написана библиотека ts-serializable которой я хочу поделиться с вами.

В чем суть проблемы показывает следующий код:

export class User {
    public firstName: string = "Иван";
    public lastName: string = "Петров";
    public birthDate: Date = new Date();
    
    public getFullName(): string {
        return [this.firstName, this.lastName].join(' ');
    }
 
    public getAge(): number {
        return new Date().getFullYear() - this.birthDate.getFullYear();
    }
}

const ivan = new User();
ivan.getFullName(); // возвращает ФИО
ivan.getAge(); // возвращает возраст
ivan instanceof User; // является инстансом пользователя

const text = JSON.stringify(ivan); // сериализуем класс в текст
const newIvan = JSON.parse(text); // десериализуем обратно

newIvan.getFullName(); // ОшибкаТипа: метод getFullName отсутствует
newIvan.getAge(); // ОшибкаТипа: метод getAge отсутствует
newIvan instanceof User; // не является инстансом пользователя

В чем причина ошибок нового Ивана? Дело в том что метод JSON.parse десериализует не в класс User, а в класс Object у которого просто нету методов getFullName и getAge.

Как же моя библиотека помогает решить эту проблему и десериализовать в User, а не Object? Достаточно просто слегка модифицировать код:

import { jsonProperty, Serializable } from "ts-serializable";
 
export class User extends Serializable {
    @jsonProperty(String)
    public firstName: string = "Иван";
    @jsonProperty(String)
    public lastName: string = "Петров";
    @jsonProperty(Date)
    public birthDate: Date = new Date();
    
    public getFullName(): string {
        return [this.firstName, this.lastName].join(' ');
    }
 
    public getAge(): number {
        return new Date().getFullYear() - this.birthDate.getFullYear();
    }
}

const ivan = new User();
ivan.getFullName(); // возвращает ФИО
ivan.getAge(); // возвращает возраст
ivan instanceof User; // является инстансом пользователя

const text = JSON.stringify(ivan); // сериализуем класс в текст
const newIvan = new User().fromJson(JSON.parse(text)); // десериализуем обратно в User

newIvan.getFullName(); // возвращает ФИО
newIvan.getAge(); // возвращает возраст
newIvan instanceof User; // является инстансом пользователя

Все очень просто. Наследуем наш класс от класса Serializable у которого есть два метода fromJson для десериализации и toJSON для сериализации, а свойствам вешаем декоратор @jsonProperty с указанием тех типов данных которые разрешено принимать из JSON. Невалидные данные будут проигнорированы, в консоль выдано предупреждение, а в свойстве останется значение по умолчанию.

Вот вообщем то и все. Теперь на фронте можно десериализовать и сериализовать так же просто как это делается в C#, Java и других языках. За основу взято поведение Newtonsoft Json.NET.

FAQ


Для чего наследоваться от Serializable?

Для того что бы добавить в модель два метода fromJson и toJSON. Можно тоже самое реализовать и через декоратор или monkey patching. Но наследование является более правильным методом для Typescript.

Как происходит валидация данных

В декоратор необходимо назначить конструктор тех типов данных которые разрешено принимать из JSON. Объекты Boolean, String, Number выдадут соответственно boolean, string, number. Если вам нужно принять массив, то тип обрамляется скобочками массива, например @jsonProperty([String]). Если конструктор отнаследован от класса Serializable, то оно также будет десериализовано в класс, если нет то вернется объект.

Как отловить ошибки валидации?

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

Бонус 1. Глубокая копия.


const user1 = new Uesr();
const user2 = new User().fromJson(user1); // создаст глубокую копию

Бонус 2. Ленивые ViewModels.


Если вам необходимо создать модель с дополнительными данными, например для вьюхи, но которые не принимает бекенд, можно просто расширить модель новыми свойствами и пометить эти свойства декоратором @jsonIgnore. И тогда эти свойства не будут сериализованы.

import { jsonProperty, Serializable } from "ts-serializable";

export class User extends Serializable {
    @jsonProperty(String)
    public firstName: string = "Иван";
    @jsonIgnore()
    public isExpanded: boolean = false;
}

JSON.stringify(new User()); // вернет {"firstName":"Иван"}

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


  1. keenondrums
    05.11.2018 20:15

    А чем не угодили class-transformer вместе с class-validator?
    https://github.com/typestack/class-transformer


  1. keenondrums
    05.11.2018 20:16

    А чем не угодили class-transformer вместе с class-validator?


    https://github.com/typestack/class-transformer


    https://github.com/typestack/class-validator/


    1. LabEG Автор
      05.11.2018 21:56

      Моей реализации на самом деле уже почти 4 года, в текущем виде больше 2 лет. И на тот момент когда я ее создавал не было той библиотеки которая меня устраивала бы по функционалу и качеству. Да и сейчас кажется что моя проще.


  1. DimPal
    05.11.2018 20:55

    После конфускации с обезличиванием имен будет счастье…
    Кстати, что будет с типизированными массивами и вложенными классами?


    1. LabEG Автор
      05.11.2018 22:02

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


  1. VolCh
    05.11.2018 21:06
    +2

    > Можно тоже самое реализовать и через декоратор или monkey patching. Но наследование является более правильным методом для Typescript.

    Вот не согласен. Особенно учитывая, что множественного наследования нет. Если у меня User extends Person, а над Person контроля нет, как мне применить ваш Serializable?


    1. LabEG Автор
      05.11.2018 22:07

      Такой кейс мне не встречался, т.к. я всегда принимаю данные в свои классы, но вопрос интересный.
      Из того что пришло в голову это сделать доработку библиотеки что бы в User переопределить свойства, но уже с декораторами, а у Serializable сделать статичный метод Serializable.toClass(User, json): User


  1. justboris
    06.11.2018 01:35

    Смешная пирамида деклараций вот здесь: github.com/LabEG/Serializable/blob/master/src/models/AcceptedType.ts#L13

    А зачем это?


    1. LabEG Автор
      06.11.2018 21:33

      Это рекурсивный тип, аналог

      type AcceptedTypes = AcceptedType | Array<AcceptedTypes>;
      для глубоко вложенных массивов типа [[[[1,2]]]]. Дело в том что typescript разрешает рекурсивные типы, но перестает валидировать их при использовании. А такая пирамида позволяет провалидировать до 10 уровня, а дальше уже через any.


      1. justboris
        06.11.2018 23:24

        А если сделать так:


        interface RecursiveArray<T> extends Array<T|RecursiveArray<T>> {}
        
        type AcceptedType = ...;
        type AcceptedTypes = RecursiveArray<AcceptedType>;

        Живое демо, валидируется нормально.
        Подсмотрено в тайпингах Lodash.


  1. kemsky
    06.11.2018 03:50

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


    Сейчас есть интересная альтернатива — TypeScript Custom Transformers, что позволяет делать кодогенерацию во время компиляции, еще не все сделано, но уже можно пробовать. По идее можно будет обойтись без кучи декораторов. Еще один способ — кодогенерация по моделям бэкенда или свагеру.


    1. LabEG Автор
      06.11.2018 21:50

      Я тоже не сразу написал библиотеку. С начало это производилось вручную в методе объекта, потом была выявлена закономерность, а в тайпскрипте появились декораторы и рефлексия. И как только появилось свободное время реализовал библиотеку.

      Со временем работать просто. Если нужно исключить влияние таймзоны надо оставить его строкой, если нет, то десериализовать в локальное время.

      TypeScript Custom Transformers — спасибо за наводку. Кажется эта штука избавит нас от browserify/webpack и позволит компилировать в ES2015.

      Про кодогенерацию моделей и репозиториев с валидацией из свагера тоже есть идея, надеюсь скоро удастся реализовать.


  1. DimPal
    06.11.2018 07:57

    AFAIK, декораторы все еще экспериментальная фича, или я что то пропустил?


    1. LabEG Автор
      06.11.2018 21:53
      -1

      Да, но она стабильна и последние два года не менялась. И думаю что уже скоро станет стандартом.


  1. metalalisa
    06.11.2018 12:27

    Если не хочется писать классы, есть github.com/gcanti/io-ts


    1. LabEG Автор
      06.11.2018 21:59

      Мне не нравится подход со схемами, кроме того в моделях у меня бывают методы.


  1. boblgum
    06.11.2018 22:00

    За саму реализацию автору однозначно плюс в карму.
    Но я советую как можно быстрее уходить от такого подхода. Вы двигаетесь в сторону ActiveRecord. В данном случае вы впиливаете функционал сохранения данных в Вашу модель. Этот функционал ни каким образом не относится к смыслу модели.
    Вынесите этот функционал в сервис и получите более чистый и распределенный код.


    1. LabEG Автор
      06.11.2018 22:08

      А вы правы, в комментарии выше уже всплывала эта проблема. И в Newtonsoft десериализация тоже реализована через статический метод. Реализую как Serializable.toClass(User, json): User