62

Are there any means for JSON serialization/deserialization of Typescript objects so that they don't lose type information? Simple JSON.parse(JSON.stringify) has too many caveats.

Or I should use adhoc solutions?

Nik
  • 9,063
  • 7
  • 66
  • 81

7 Answers7

61

Use Interfaces to get strong types:

// Creating 
var foo:any = {};
foo.x = 3;
foo.y='123';

var jsonString = JSON.stringify(foo);
alert(jsonString);


// Reading
interface Bar{
    x:number;
    y?:string; 
}

var baz:Bar = JSON.parse(jsonString);
alert(baz.y);

And use type assertion "<>" if you need to.

basarat
  • 261,912
  • 58
  • 460
  • 511
22

I think a better way to handle this is to use Object.assign (which however requires ECMAScript 2015).

Given a class

class Pet {
    name: string;
    age: number;
    constructor(name?: string, age?: number) {
        this.name = name;
        this.age = age;
    }
    getDescription(): string {
        return "My pet " + this.name + " is " + this.age + " years old.";
    }
    static fromJSON(d: Object): Pet {
        return Object.assign(new Pet(), d);
    }
}

Serialize and deserialize like this...

var p0 = new Pet("Fido", 5);
var s = JSON.stringify(p0);
var p1 = Pet.fromJSON(JSON.parse(s));
console.log(p1.getDescription());

To take this example to the next level, consider nested objects...

class Type {
    kind: string;
    breed: string;
    constructor(kind?: string, breed?: string) {
        this.kind = kind;
        this.breed = breed;
    }
    static fromJSON(d: Object) {
        return Object.assign(new Type(), d);
    }
}
class Pet {
    name: string;
    age: number;
    type: Type;
    constructor(name?: string, age?: number) {
        this.name = name;
        this.age = age;
    }
    getDescription(): string {
        return "My pet " + this.name + " is " + this.age + " years old.";
    }
    getFullDescription(): string {
        return "My " + this.type.kind + ", a " + this.type.breed + ", is " + this.age + " years old.";
    }
    static fromJSON(d: Object): Pet {
        var o = Object.assign(new Pet(), d);
        o.type = Type.fromJSON(o['type']);
        return o;
    }
}

Serialize and deserialize like this...

var q0 = new Pet("Fido", 5);
q0.type = new Type("dog", "Pomeranian");
var t = JSON.stringify(q0);
var q1 = Pet.fromJSON(JSON.parse(t));
console.log(q1.getFullDescription());

So unlike using an interface, this approach preserves methods.

AQuirky
  • 4,691
  • 2
  • 32
  • 51
  • Problem with this is that you need to allow a no-argument constructor, which initializes objects in an incomplete state. This violates the RAII principle, and can/will confuse users of your library. – samlaf Mar 25 '22 at 16:03
  • I found a way to mitigate this problem. See my answer: https://stackoverflow.com/a/71623375/4971151 – samlaf Mar 25 '22 at 21:37
3

The best method I found so far was to use "jackson-js". jackson-js is a project that allows you to describe the class using ts-decorators and then serialize and desirialize saving the type information. It supports arrays, maps, etc.

Full tutorial: https://itnext.io/jackson-js-powerful-javascript-decorators-to-serialize-deserialize-objects-into-json-and-vice-df952454cf

Simple example:

import { JsonProperty, JsonClassType, JsonAlias, ObjectMapper } from 'jackson-js';

class Book {
  @JsonProperty() @JsonClassType({type: () => [String]})
  name: string;
  @JsonProperty() @JsonClassType({type: () => [String]})
  @JsonAlias({values: ['bkcat', 'mybkcat']})
  category: string;
}

class Writer {
  @JsonProperty() @JsonClassType({type: () => [Number]})
  id: number;
  @JsonProperty() @JsonClassType({type: () => [String]})
  name: string;
  @JsonProperty() @JsonClassType({type: () => [Array, [Book]]})
  books: Book[] = [];
}

const objectMapper = new ObjectMapper();
// eslint-disable-next-line max-len
const jsonData = '{"id":1,"name":"John","books":[{"name":"Learning TypeScript","bkcat":"Web Development"},{"name":"Learning Spring","mybkcat":"Java"}]}';
const writer = objectMapper.parse<Writer>(jsonData, {mainCreator: () => [Writer]});
console.log(writer);
/*
Writer {
  books: [
    Book { name: 'Learning TypeScript', category: 'Web Development' },
    Book { name: 'Learning Spring', category: 'Java' }
  ],
  id: 1,
  name: 'John'
}
*/

There are a few other projects that claim to do the same thing -

However, jackson-js is the only one that worked for me when I used a TypeScript Map.

Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
Gabriel H
  • 1,558
  • 2
  • 14
  • 35
1

First, you need to create an interface of your source entity which you receive from the API as JSON:

interface UserEntity {
  name: string,
  age: number,
  country_code: string
};

Second, implement your model with constructor where you can customize (camelize) some field names:

  class User  {
    constructor({ name, age, country_code: countryCode }: UserEntity) {
      Object.assign(this, { name, age, countryCode });
    }
  }

Last, create an instance of your User model using JavaScript object "jsonUser"

    const jsonUser = {name: 'Ted', age: 2, country_code: 'US'};

    const userInstance = new User(jsonUser);

    console.log({ userInstance })

Result log

Here is the link to playground.

Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
1

I would also suggests using ts-jackson

It is build with typescript in mind and allows to resolve deeply nested structures.

Elias
  • 698
  • 8
  • 23
-1

The AQuirky answer works for me. You may have some troubles with the Object.assign method. I had to modify my tsconfig.json to include:

"compilerOptions": {
    ...
    "lib": ["es2015"],
    ...
}
nexiss
  • 37
  • 7
-1

AQuirky's answer is a good starting point, but as mentioned in my comment, its main problem is that it needs to allow creating objects with undefined fields, which are then populated by his fromJSON method.

This violates the RAII principle, and can/will confuse users of that class who might fall into the trap of creating an incomplete Pet (nowhere is it explicit that calling the constructor without arguments must be followed by a call to fromJSON() to populate the object).

So building on his answer, here's one way, using JavaScript's prototype chain, to get back an object of a class after serializing/deserializing. The key trick is just to reassign the correct prototype object after serializing and deserializing:

class Foo {}
foo1 = new Foo();
foo2 = JSON.parse(JSON.stringify(p1))
foo2.__proto__ = Foo.prototype;

Enter image description here

So to fix AQuirky's example using this trick, we could simply change his fromJSON function to

static fromJSON(d: Object): Pet {
    d.__proto__ = Pet.prototype;
    return p
}
Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
samlaf
  • 425
  • 4
  • 9