What's the type of JSON.parse(JSON.stringify(x))?
source link: https://effectivetypescript.com/2020/04/09/jsonify/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
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 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 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>; // type T1 = { // id: number; // name: string; // birthday: string | null; // } interface Class { valedictorian: Student; salutatorian?: Student; } type T2 = Jsonify<Class>; // type T2 = { // 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 T = { // 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.
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK