Thu 09 April 2020

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 Date s get converted to string s. 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 T2 = Jsonify< Date >;

type T3 = Jsonify< boolean >;



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

type T = Jsonify< Date | null >;



But what about object types? Usually the Date s 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>;













interface Class {

valedictorian: Student;

salutatorian?: Student;

}

type T2 = Jsonify<Class>;



























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

interface Class {

teacher: string ;

start: Date ;

stop: Date ;

students: Student[];

}

type T = Jsonify<Class>;























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[];



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);













const objWithToJSON = {

x: 5 , y: 6 , toJSON(){ return this .x + this .y; }

};

const objRT = jsonRoundTrip(objWithToJSON);





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 !