Deserializing JSON in TypeScript

This article looks at options for deserializing JSON in JavaScript (TypeScript).

Table of contents

JSON and JavaScript

If you have ever worked with JSON in an Angular application, you know that you cannot trust the deserialized results. JavaScript doesn't have Reflection, JSON doesn't have a data type for a Date and so... so JSON.parse doesn't know that you want the incoming string value "2018-07-15T05:35:03.000Z" converted to a Date.

Great!

Say you have defined an interface Order as:

export interface Order {
    orderId?: number;
    orderNumber?: string;
    pickupDateTime?: Date
}

And you call your Angular HttpClient with the generic HttpClient#get<T> overload:

this.httpClient
    .get<Order>(url, ...)
    .pipe(
        map((response: Order) => {
            // Work with the Response ...
        })
    );

Your Webservice responds with a totally valid JSON Order as:

{ 
    "orderId": 1,
    "orderNumber": "8472-423-14",
    "pickupDateTime":"2018-07-15T05:35:03.000Z"
}

And with a simple test, we can see, that the returned pickupDateTime is actually a string and not a Date:

describe('JSON.parse', () => {

    it('Should not parse string as Date', () => {

        // prepare
        const json = `
        { 
            "orderId": 1,
            "orderNumber": "8472-423-14",
            "pickupDateTime":"2018-07-15T05:35:03.000Z"
        }`;

        // act
        const verifyResult = JSON.parse(json) as Order;

        // verify
        const isTypeOfString = typeof verifyResult.pickupDateTime === "string";
        const isInstanceOfDate = verifyResult.pickupDateTime instanceof Date;

        deepStrictEqual(isTypeOfString, true);
        deepStrictEqual(isInstanceOfDate, false);
    }); 

});

In this example we will take a look at different ways to get the correct results for our deserialized JSON.

All code can be found in a GitHub repository at:

Solving the Problem

Manual Conversion

The most obvious way to convert JSON data is to reflect by hand, and manually handroll the deserialization. While this involves a lot of typing, though I am pretty sure most of it can be generated.

It involves the least amount of magic and it doesn't involve any TypeScript cleverness. Absolutely everyone in a team understands this conversion and that should never be underestimated. I want to go on holiday and know all people get along just fine.

So what I would do is to basically ship a Converters class, which contains converters for all classes involved. Problem solved. And if the class gets huge you could break it down into an OrderConverter, CustomerConverter and so on...

export class Converters {

    public static convertToCustomerArray(data: any): Customer[] | null {
        return Array.isArray(data) ? data.map(item => Converters.convertToCustomer(item)): undefined;
    }

    public static convertToCustomer(data: any): Customer | undefined {
        return data ? {
            customerId: data["customerId"],
            customerName: data["customerName"],
            orders: Converters.convertToOrderArray(data["orders"])
        } : undefined;
    }

    public static convertToOrderArray(data: any): Order[] | undefined {
        return Array.isArray(data) ? data.map(item => Converters.convertToOrder(item)) : undefined;
    }

    public static convertToOrder(data: any): Order | null {
        return data ? {
            orderId: data["orderId"],
            orderNumber: data["orderNumber"],
            pickupDateTime: data["pickupDateTime"] 
                ? new Date(data["pickupDateTime"]) : undefined
        } : undefined;
    }

    public static convertDateArray(data: any) : Date[] | undefined {
        return Array.isArray(data) ? data.map(item => new Date(item)) : undefined;
    }
}

Deserialization using Decorators

So JavaScript removes the types at runtime and there is no Reflection. Then how on earth does Angular provide all this Dependency Injection voodoo? By using Decorators! You have probably come across a decorator like @Component in your Angular development?

So the idea is to provide a set of decorators like @JsonProperty, @JsonType, ... to decorate classes and deserialize the JSON into the correct types. It will look like this:

export class Order {

    @JsonProperty("orderId")
    orderId?: number;

    @JsonProperty("orderNumber")
    orderNumber?: string;

    @JsonProperty("pickupDateTime")
    @JsonType(Date)
    pickupDateTime?: Date;
}

And with a simple test, we will see the correct types on our deserialized object:

describe('deserialize', () => {

    it('Decorated Order should parse Date', () => {
        // prepare        
        const json = `
        { 
            "orderId": 1,
            "orderNumber": "8472-423-14",
            "pickupDateTime":"2018-07-15T05:35:03.000Z"
        }`;

        // act
        const verifyResult: Order = deserialize<Order>(json, Order);

        // verify
        const isTypeOfObject = typeof verifyResult.pickupDateTime === "object";
        const isInstanceOfDate = verifyResult.pickupDateTime instanceof Date;

        deepStrictEqual(isTypeOfObject, true);
        deepStrictEqual(isInstanceOfDate, true);
    }); 
});

That enables us a bit more type-safety, when deserializing incoming JSON data.

We start by enabling the secret sauce tsconfig.json of our project:

{
  "compilerOptions": {
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true
  }
}

What's happening under the hood is, that TypeScript generates two helper functions __decorate and __metadata when it translates to JavaScript:

var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
    var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
    if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
    else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
    if (typeof Reflect === "object" && typeof Reflect.metadata === "function") 
        return Reflect.metadata(k, v);
};

Now assume we have added decorators to our class, like this:

export class Order {

    @JsonProperty("orderId")
    orderId?: number;

    @JsonProperty("orderNumber")
    orderNumber?: string;

    @JsonProperty("pickupDateTime")
    @JsonType(Date)
    pickupDateTime?: Date;

}

Then tsc will generate the following JavaScript:

var Order = exports.Order = /** @class */ (function () {
    function Order() {
    }
    __decorate([
        (0, deserializer_1.JsonProperty)("orderId"),
        __metadata("design:type", Number)
    ], Order.prototype, "orderId", void 0);
    __decorate([
        (0, deserializer_1.JsonProperty)("orderNumber"),
        __metadata("design:type", String)
    ], Order.prototype, "orderNumber", void 0);
    __decorate([
        (0, deserializer_1.JsonProperty)("pickupDateTime"),
        (0, deserializer_1.JsonType)(Date),
        __metadata("design:type", Date)
    ], Order.prototype, "pickupDateTime", void 0);
    return Order;
}());

I won't go into details here, but we can see, that __decorate and __metadata use the Reflect API polyfill. The Reflect API internally creates WeakMap to store the Metadata and uses the Object itself as the key.

Now with the Reflect API magic explained, let's implement the JSON deserializer. The deserializer example is based on the great typeserializer library by Dan Revah, which is available at:

So all credit goes to him. It's such a clever and great implementation.

We start by adding the reflect-metadata polyfill, because the Reflect API isn't an official standard:

import 'reflect-metadata';

Metadata can only be attached to objects and we are working in JavaScript, so we need helper methods to know at runtime, if a given value is defined and is an object:

// Helpers
export function isObject(val: any) {
    return typeof val === 'object' && val !== null && !Array.isArray(val);
}

export function isUndefined(val: any) {
    return typeof val === 'undefined';
}

We can then add a createDecorator method to create a Decorator function, that we can then use to decorate properties and classes.

// Creates a Decorator
export function createDecorator(name: string, keySymbol: Symbol, value: any) {
    return function <T extends Object>(target: T, key: keyof T) {
        const obj = Reflect.getMetadata(keySymbol, target) || {};

        if (!isUndefined(obj[key])) {
            throw new Error(
                `Cannot apply @${name} decorator twice on property '${String(key)}' of class '${(target as any).constructor.name}'.`
            );
        }

        Reflect.defineMetadata(keySymbol, { ...obj, ...{ [key]: value } }, target);
    };
}

Next we define the set of Symbol, that serve as the keys for the metadata:

export const JsonPropertySymbol = Symbol('JsonProperty');
export const JsonTypeSymbol = Symbol('JsonType');
export const JsonConverterSymbol = Symbol('JsonConverter');

Next we create the set of decorator functions, that we can apply using the special @ syntax we have enabled in the tsconfig.json:

// Decorators
export function JsonProperty(name: string) {
    return createDecorator("JsonProperty", JsonPropertySymbol, name);
}

export function JsonType(type: any) {
    return createDecorator("JsonType", JsonTypeSymbol, type);
}

export function JsonConverter<T>(fn: (value: any, obj: any) => T) {
    return createDecorator("JsonConverter", JsonConverterSymbol, fn);
}

Then we can implement a deserialize method, that takes a JSON string and class type. We need a class type, so we can access the Metadata. Remember: The Reflect API stores all Metadata using an Object as key.

By using Reflect.getMetadata on the target instance we can reflect the Metadata associated and then map the JSON object accordingly.

// Deserializer
export function deserialize<T>(json: string, classType: any): any {
    return transform(toObject(json), classType);
}

// Transformers
export function transformArray(arr: any[], classType: any): any[] {
    return arr.map((elm: any) => (Array.isArray(elm) ? transformArray(elm, classType) : transform(elm, classType)));
}

export function transform(obj: any, classType: any) {

    // If the given value is not an object, we cannot reflect
    if (!isObject(obj)) {
        return obj;
    }

    // Create an instance, so we can reflect the Decorator metadata:
    const instance = new classType();

    // Reflects the Metadata associated with each property:
    const jsonPropertyMap = Reflect.getMetadata(JsonPropertySymbol, instance) || {};
    const jsonTypeMap = Reflect.getMetadata(JsonTypeSymbol, instance) || {};
    const jsonConverterMap = Reflect.getMetadata(JsonConverterSymbol, instance) || {};

    // Maps the Name to the Property
    const nameToPropertyMap = Object.keys(jsonPropertyMap)
        .reduce((accumulator: any, key: string) => ({ ...accumulator, [jsonPropertyMap[key]]: key }), {});

    Object.keys(obj).forEach((key: string) => {
        if (nameToPropertyMap.hasOwnProperty(key)) {
            instance[nameToPropertyMap[key]] = obj[key];
        } else {
            instance[key] = obj[key];
        }

        if (typeof jsonConverterMap[key] === 'function') {

            instance[key] = jsonConverterMap[key].call(null, instance[key], instance);
            return;
        }

        if (!jsonTypeMap.hasOwnProperty(key)) {
            return;
        }

        const type = jsonTypeMap[key];

        if (Array.isArray(type)) {
            instance[key] = transformArray(obj[key], type[0]);
        } else if (type === Date) {
            instance[key] = new Date(obj[key]);
        } else {
            instance[key] = transform(obj[key], type);
        }
    });

    return instance;
}

Adding Unit Tests for both implementations

In the deserializer.spec.ts class, we are adding tests to ensure both approaches work correctly.

import { describe, it, } from 'mocha';
import { Temporal } from "@js-temporal/polyfill";
import { deepStrictEqual } from "assert";

import { deserialize, isObject, JsonConverter, JsonProperty, JsonType, transform, transformArray } from './deserializer';

const fixtureChildOrder1 = `
    { 
        "orderId": 1,
        "orderNumber": "8472-423-14",
        "pickupDateTime":"2018-07-15T05:35:03.000Z"
    }`;

const fixtureChildOrder2 = `
    { 
        "orderId": 2,
        "orderNumber": "1341-7856-75189",
        "pickupDateTime":"2019-01-12T01:15:03.000Z"
    }`;

const fixtureCustomer = `
    {
        "customerId": 4,
        "customerName": "Northwind Toys",
        "orders": [
            ${fixtureChildOrder1},
            ${fixtureChildOrder2}
        ]
    }
`;

const fixturePlainDateExample = `
    {
        "shippingDate": "2012-01-01"
    }
`;

export class JsonConverterExample {

    @JsonProperty("shippingDate")
    @JsonConverter((val) => Temporal.PlainDate.from(val))
    shippingDate?: Temporal.PlainDate;

}

const fixtureDifferentNameExample = `
    {
        "myShippingDate": "2012-01-01"
    }
`;

export class JsonPropertyExample {

    @JsonProperty("myShippingDate")
    shippingDate?: string;
}

const fixtureODataEntityResponse = `
    {
        "@odata.context" : "http://localhost:5000/odata/#Customer",
        "@odata.count" : 2,
        "orderId": 2,
        "orderNumber": "1341-7856-75189",
        "pickupDateTime":"2019-01-12T01:15:03.000Z"
    }
`;

const fixtureODataEntitiesResponse = `
    {
        "@odata.context" : "http://localhost:5000/odata/#Customer",
        "@odata.count" : 2,
        "value" : [
            ${fixtureChildOrder1},
            ${fixtureChildOrder2} 
        ]
    }
`;

export class Order {

    @JsonProperty("orderId")
    orderId?: number;

    @JsonProperty("orderNumber")
    orderNumber?: string;

    @JsonProperty("pickupDateTime")
    @JsonType(Date)
    pickupDateTime?: Date;

}

export class Customer {

    @JsonProperty("orderId")
    customerId?: number;

    @JsonProperty("customerName")
    customerName?: string;

    @JsonProperty("orders")
    @JsonType([Order])
    orders?: Order[];
}

export class OrderWithoutDecorators {

    orderId?: number;

    orderNumber?: string;

    pickupDateTime?: Date;
}

describe('JSON.parse', () => {

    it('Should not parse string as Date', () => {

        // prepare
        const json = `
        { 
            "orderId": 1,
            "orderNumber": "8472-423-14",
            "pickupDateTime":"2018-07-15T05:35:03.000Z"
        }`;

        // act
        const verifyResult = JSON.parse(json) as Order;

        // verify
        const isTypeOfString = typeof verifyResult.pickupDateTime === "string";
        const isInstanceOfDate = verifyResult.pickupDateTime instanceof Date;


        deepStrictEqual(isTypeOfString, true);
        deepStrictEqual(isInstanceOfDate, false);
    }); 

});

describe('deserialize', () => {

    it('Decorated Order should parse Date', () => {
        // prepare        
        const json = `
        { 
            "orderId": 1,
            "orderNumber": "8472-423-14",
            "pickupDateTime":"2018-07-15T05:35:03.000Z"
        }`;

        // act
        const verifyResult: Order = deserialize<Order>(json, Order);

        // verify
        const isTypeOfObject = typeof verifyResult.pickupDateTime === "object";
        const isInstanceOfDate = verifyResult.pickupDateTime instanceof Date;

        deepStrictEqual(isTypeOfObject, true);
        deepStrictEqual(isInstanceOfDate, true);
    }); 


    it('Should convert Customer with manual conversion', () => {
        // prepare
        const data = JSON.parse(fixtureCustomer);

        // act
        const verifyResult: Customer = Converters.convertToCustomer(data);

        // verify
        const isTypeOfObject = typeof verifyResult.orders[0].pickupDateTime === "object";
        const isInstanceOfDate = verifyResult.orders[0].pickupDateTime instanceof Date;

        deepStrictEqual(isTypeOfObject, true);
        deepStrictEqual(isInstanceOfDate, true);

        deepStrictEqual(verifyResult.orders[0].pickupDateTime, new Date("2018-07-15T05:35:03.000Z"));
        deepStrictEqual(verifyResult.orders[1].pickupDateTime, new Date("2019-01-12T01:15:03.000Z"));
    }); 

    it('Should Convert Child Array and Dates', () => {
        // prepare
        // act
        const customer: Customer = deserialize(fixtureCustomer, Customer);

        // verify
        deepStrictEqual(customer.orders[0].pickupDateTime, new Date("2018-07-15T05:35:03.000Z"));
        deepStrictEqual(customer.orders[1].pickupDateTime, new Date("2019-01-12T01:15:03.000Z"));
    }); 

    it('Should Convert Plain Date', () => {
        // prepare
        // act
        const verifyResult: JsonConverterExample = deserialize(fixturePlainDateExample, JsonConverterExample);

        deepStrictEqual(verifyResult.shippingDate, Temporal.PlainDate.from("2012-01-01"));
    }); 

    it('Should Convert by Name', () => {
        // prepare
        //act
        const verifyResult: JsonConverterExample = deserialize(fixtureDifferentNameExample, JsonPropertyExample);

        // verify
        deepStrictEqual(verifyResult.shippingDate, "2012-01-01");
    }); 

    // ...
});

Conclusion

So what's the best way with TypeScript to deserialize a given JSON object?

I have to admit, that @JsonProperty and @JsonType decorators look great on the outside. They make the whole deserialization such a clean thing. You could easily add decorators like @JsonRequired to require certain properties during deserialization, one can imagine decorators like @JsonValidate decorator to validate incoming data.

But with a manual conversion... I can just debug and step through the whole thing, and get the freedom to do whatever I want with the incoming data. What would have been a @JsonRequired decorator will be a function Converters#require, so what?

See I am working on an OData-based application, so I have access to all schema elements, their properties and types. That makes it possible to simply generate all TypeScript interfaces and required converter functions... no magic included and that's most probably the way I would go.