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?
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.
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.
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.
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 })
I would also suggests using ts-jackson
It is build with typescript in mind and allows to resolve deeply nested structures.
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;
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
}
d?For Typescript, @badcafe/jsonizer https://badcafe.github.io/jsonizer/ is much less verbose than ts-jackson, because you write mappings only when it is usefull (when you want an instance of a class, not for basic JS types) :
npm install @badcafe/jsonizer
Simple case :
import { Reviver, Jsonizer } from '@badcafe/jsonizer';
@Reviver<Book>({
// 👆 bind the reviver to the class
'.': Jsonizer.Self.assign(Book)
// 👆 '.' key is the Self builder, that will assign each field
})
class Book {
name: string;
category: string;
}
@Reviver<Writer>({
'.': Jsonizer.Self.assign(Writer),
birthDate: Date, // 👈 I have added this field in the class
books: { // 👈 Typescript knows it's an array
'*': Book
// 👆 '*' key is the "any item" matcher for arrays
}
})
class Writer {
id: number;
name: string;
birthDate: Date;
books: Book[] = [];
}
// eslint-disable-next-line max-len
const jsonData = '{"id":1,"name":"John","birthDate":"1990-12-31","books":[{"name":"Learning TypeScript","bkcat":"Web Development"},{"name":"Learning Spring","mybkcat":"Java"}]}';
const writerReviver = Reviver.get(Writer); // 👈 extract the reviver from the class
// just use standard Javascript parse function :
const writer = JSON.parse(jsonData, writerReviver);
// 👆 it's an instance of Writer, and in the code, it's type is inferred to be a Writer (usually, it is TS any)
Below, with some variants :
import { Reviver } from '@badcafe/jsonizer';
// if the JSON has not the same shape of the class,
// we may describe it (usually, it's a DTO) :
interface BookDTO {
name: string
category?: string
bkcat?: string
mybkcat?: string
}
@Reviver<Book, BookDTO>({
// 👇 this time, we have a custom builder
'.': ({ name, category, bkcat, mybkcat}) => {
const book = new Book();
book.name = name;
book.category = category ?? bkcat ?? mybkcat;
return book;
}
})
class Book {
name: string;
category: string;
}
Below, we have a variant for the Writer class
First, the class :
export class Writer {
constructor( // 👈 we have to pass args to the constructor
public id: number,
public name: string,
public birthDate: Date,
public books: Book[] = []
) {}
}
import { Reviver, Jsonizer } from '@badcafe/jsonizer';
import { Writer, Book } from '...';
// 👇 it is no longer a decorator
Reviver<Writer>({
'.': Jsonizer.Self.apply(Writer), // 👈 'apply' instead of 'assign'
birthDate: Date,
books: {
'*': Book
}
})(Writer) // 👈 bind the reviver to the class
// 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 writerReviver = Reviver.get(Writer);
const writer = JSON.parse(jsonData, writerReviver);
Since you can use builders helpers like Jsonizer.Self.assign() and Jsonizer.Self.apply() and also custom builders as in the example above, it is easy to recreate other structures such as a Map ; to serialize it, just define the standard toJSON() function to your class.
@badcafe/jsonizer can do much more, for example :
Category of writer and a Category of books, you may assign them a namespace (this is very helpful if you import classes made in other libs, their name may clash !)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"],
...
}