TypeScript: Working with JSON

EDITS:

Calling toString on Date is for illustrative purposes.

on is for illustrative purposes. There’s a full commented example at the end.

Use toJSON method as suggested by Schipperz.

method as suggested by Schipperz. Add reviver method as suggested by Anders Ringqvist.

So you have a User type in your code.

interface User { name: string; age: number; created: Date; }

At some point you’re going to want to encode this as JSON. This works as you’d expect.

> JSON.stringify({ name: "bob", age: 34, created: new Date() }); '{"name":"bob","age":34,"created":"2016-03-19T18:15:12.710Z"}'

The problem is that the created field is no longer a Date when you parse it back.

> JSON.parse('{"name":"bob","age":34,"created":"2016-03-19T18:15:12.710Z"}') { name: 'bob', age: 34, created: '2016-03-19T18:15:12.710Z' }

The way I went about fixing this is by introducing a UserJSON interface.

Since it only contains primitives, it can be converter to and from JSON without altering it.

interface UserJSON { name: string; age: number; created: string; }

Then I convert from User -> UserJSON before ‘stringifying’ to JSON and convert from UserJSON -> User after parsing from JSON. Here’s an example of some client code doing this.

function getUsers(): Promise<User[]> { return ajax.get<UserJSON[]>('/users').then(data => { return data.data.map(decodeUser); }); } function updateUser(id: number|string, user: User): Promise<{}> { return ajax.put<{}>(`/users/${id}`, encodeUser(user)); }

Here are the conversion functions.

function encodeUser(user: User): UserJSON { return { name: user.name, age: user.age, created: user.created.toString() }; } function decodeUser(json: UserJSON): User { return { name: json.name, age: json.age, created: new Date(json.created) }; }

This works, but it’s a contrived example. In real cases, there will be a lot more properties and this quickly turns into a huge pain in the ass. Let’s use Object.assign to clean it up a bit.

function encodeUser(user: User): UserJSON { return Object.assign({}, user, { created: user.created.toString() }); } function decodeUser(json: UserJSON): User { return Object.assign({}, json, { created: new Date(json.created) }); }

So far so good, but what happens when User is a class?

class User { private created: Date; constructor( private name: string, private age: string ) { this.created = new Date(); } getName(): string { return this.name; } }

For this to work, I use Object.create to make a new instance of User without using the constructor. Then assign the properties to that. The encoding function doesn’t change.

function decodeUser(json: UserJSON): User { let user = Object.create(User.prototype); return Object.assign(user, json, { created: new Date(json.created) }); }

Finally, the encode and decode functions can just be methods on the User class.

class User { private created: Date; constructor( private name: string, private age: string ) { this.created = new Date(); } getName(): string { return this.name; } encode(): UserJSON { return Object.assign({}, this, { created: this.created.toString() }); } static decode(json: UserJSON): User { let user = Object.create(User.prototype); return Object.assign(user, json, { created: new Date(json.created) }); } }

When JSON.stringify is invoked on an object, it checks for a method called toJSON to convert the data before ‘stringifying’ it. In light of this, let’s rename encode and decode to toJSON and fromJSON .

class User { /* ... */ toJSON(): UserJSON { return Object.assign({}, this, { created: this.created.toString() }); } static fromJSON(json: UserJSON): User { let user = Object.create(User.prototype); return Object.assign(user, json, { created: new Date(json.created) }); } }

We don’t need to call user.encode() explicitly anymore!

let data = JSON.stringify(new User("Steve", 39)); let user = User.fromJSON(JSON.parse(data));

This is good, but we can do better. JSON.parse accepts a second parameter called reviver which is a function that gets called with every key/value pair in the object as it’s being parsed. The root object is passed to reviver with an empty string as the key. Let’s add a reviver function to our User class.

class User { /* ... */ static reviver(key: string, value: any): any { return key === "" ? User.fromJSON(value) : value; } }

Now we can write:

let user = JSON.parse(data, User.reviver);

Not too shabby…

The nice thing about using this pattern is that it composes very well.

Say the user had an account property which contained an instance of Account .

class User { private account: Account; /* ... */ static fromJSON(json: UserJSON): User { let user = Object.create(User.prototype); return Object.assign(user, json, { created: new Date(json.created), account: Account.fromJSON(json.account) }); } }

And here’s the full commented User class.