What's the type of JSON.parse(​JSON.stringify(x))?

If you're writing a server in JavaScript, you might write an endpoint that converts an object to JSON:

app.get('/user', (request, response) => {
const user = getCurrentUser();
response.json(user);
});

On the client, you might use the fetch API to hit this endpoint and deserialize (parse) the data:

const response = await fetch('/user');
const user = await response.json();

What's the relationship between the user object in the server and the corresponding user object in the client? And how would you model this in TypeScript?

Because the serialization and deserialization ultimately happens via JavaScript's built-in JSON.stringify and JSON.parse functions, we can alternatively ask: what's the return type of this function?

function jsonRoundTrip<T>(x: T) {
return JSON.parse(JSON.stringify(x));
}

If you mouse over jsonRoundTrip on the TypeScript playground, you'll see that its inferred return type is any. That's not very satisfying!

It's tempting to make the return type T, so that this is like an identity function:

function jsonRoundTrip<T>(x: T): T {
return JSON.parse(JSON.stringify(x));
}

But this isn't quite right. First of all, there are many objects which can't be directly represented in JSON. A regular expression, for instance:

> JSON.stringify(/foo/)
'{}'

Second, there are some values that get transformed in the conversion process. For example, undefined in an array becomes null:

> arr = [undefined]
> jsonRoundTrip(arr)
[ null ]

With strictNullChecks in TypeScript, null and undefined have distinct types.

If an object has a toJSON method, it will get called by JSON.stringify. This is implemented by some of the standard types in JavaScript, notably Date:

> d = new Date();
> jsonRoundTrip(d)
'2020-04-09T01:07:48.835Z'

So Dates get converted to strings. Who knew? You can read the full details of how this works on MDN.

How to model this in TypeScript? Let's just focus on the behavior around Dates. For a complex mapping like this, we're going to want a conditional type:

type Jsonify<T> = T extends Date ? string : T;

This is already doing something sensible:

type T1 = Jsonify<string>; // Type is string
type T2 = Jsonify<Date>; // Type is string
type T3 = Jsonify<boolean>; // Type is boolean

We even get support for union types because conditional types distribute over unions:

type T = Jsonify<Date | null>;  // Type is string | null

But what about object types? Usually the Dates are buried somehwere in a larger type. So we'll need to make Jsonify recursive. This is possible as of TypeScript 3.7:

type Jsonify<T> = T extends Date
? string
: T extends object
? {
[k in keyof T]: Jsonify<T[k]>;
}
: T;

In the case that we have an object type, we use a mapped type to recursively apply the Jsonify transformation. This is already starting to make some interesting new types!

interface Student {
id: number;
name: string;
birthday: Date | null
}
type T1 = Jsonify<Student>;
// type is {
// id: number;
// name: string;
// birthday: string | null;
// }

interface Class {
valedictorian: Student;
salutatorian?: Student;
}
type T2 = Jsonify<Class>;
// type is {
// valedictorian: {
// id: number;
// name: string;
// birthday: string | null;
// };
// salutatorian?: {
// id: number;
// name: string;
// birthday: string | null;
// } | undefined;
// }

What if there's an array involved? Does that work?

interface Class {
teacher: string;
start: Date;
stop: Date;
students: Student[];
}
type T = Jsonify<Class>;
// type is {
// teacher: string;
// start: string;
// stop: string;
// students: {
// id: number;
// name: string;
// birthday: string | null;
// }[];
// }

It does! How was TypeScript able to figure that out?

First of all, Arrays are objects, so T extends object is true for any array type. And keyof T[] includes number, since you can index into an array with a number. But it also includes methods like length and toString:

type T = keyof Student[];  // type is number | "length" | "toString" | ...

So it's a bit of a surprise Jsonify produces such a clean type for the array. Perhaps mapped types over arrays are special cased.

But regardless, this is great! We can even loosen the definition slightly to handle any object with a toJSON() method (including Dates):

type Jsonify<T> = T extends {toJSON(): infer U}
? U
: T extends object
? {
[k in keyof T]: Jsonify<T[k]>;
}
: T;

function jsonRoundTrip<T>(x: T): Jsonify<T> {
return JSON.parse(JSON.stringify(x));
}

const student: Student = {
id: 327, name: 'Bobby', birthday: new Date('2007-10-10')
};
const studentRT = jsonRoundTrip(student);
// type is {
// id: number;
// name: string;
// birthday: string | null;
// }

const objWithToJSON = {
x: 5, y: 6, toJSON(){ return this.x + this.y; }
};
const objRT = jsonRoundTrip(objWithToJSON);
// type is number!

Here we've used the infer keyword to infer the return type of the toJSON method of the object. Try the last example out in the playground. It really does return a number!

As TypeScript Development lead Ryan Cavanaugh once said, it's remarkable how many problems are solved by conditional types. The types involved in JSON serialization are one of them! If you produce and consume JSON in a TypeScript project, consider using something like Jsonify to safely handle Dates in your objects.

Like this post? Consider subscribing to my newsletter, the RSS feed, or following me on Twitter.
Effective TypeScript Book Cover

Effective TypeScript shows you not just how to use TypeScript but how to use it well. The book's 62 items help you build mental models of how TypeScript and its ecosystem work, make you aware of pitfalls and traps to avoid, and guide you toward using TypeScript’s many capabilities in the most effective ways possible. Regardless of your level of TypeScript experience, you can learn something from this book.

After reading Effective TypeScript, your relationship with the type system will be the most productive it's ever been! Learn more »