Dealing With Date Serialization And Deserialization In JavaScript

Dealing With Date Serialization And Deserialization In JavaScript

There's a little (intentional) hole in two of JavaScript's widely used standard functions, namely in JSON.stringify and JSON.parse and it has to do with JavaScript's Date object. And this hole can sometimes lead so some headache.

We'll take a look at what this hole actually is, and you'll learn how you can effectively deal with it.

The Problem

Have you ever tried to JSON.stringify an object which contains a Date? Works like a breeze, doesn't it?

const obj = {
  date: new Date(),
  id: "foo"
};

const serialized = JSON.stringify(obj);
console.log(serialized); 
// => prints {"date":"2020-12-03T09:19:29.408Z","id":"foo"}

But have you also tried to JSON.parse the same object again, and then checked the type of the property that originally contained the date?

const deserialized = JSON.parse(serialized);
console.log(typeof deserialized.date);
// => prints string

Yes, it's a string now.

The Reason For This Behavior

You might now ask something like "Why is JSON.parse not able to restore the date properly?" and it would be a very good question. Shouldn't the function be able to determine that there is a Date? In the beginning it was also serialized as one.

Well, when JSON.stringify serializes your object, it turns the date into a string. This is how you usually serialize date objects in any language. There are well-known formats for date objects, which specify how a date should look when it is turned into a string.

But at this moment, when your date gets serialized, it looses information. The former date is now only a mere string, nothing more, nothing less, and there is no more information whether it has ever been a date. And with this loss of information, another problem arises.

Has this string ever been a date, or has it always been a string? What was the original intention of the creator?

Not all JSON objects you work with only live within your own code and program. Some of them travel over the wire when you fetch data from an API or send out data to a frontend. The object leaves your program's memory and is loaded into another program's memory again, and that program's code could have been implemented by another person, anywhere else in the world.

The creators of JSON.parse had the same problem. They needed to think about how to implement this method in a way that it works, out of the box, for as many users as possible. And this is why they decided to do nothing. Yes, nothing. They decided against implementing some special behavior to deal with strings that look like dates, because there is not enough information at hand to properly deal with the issue.

View it this way: Developers who intentionally created only a formatted date string would be surprised or even angry if their users always got a date object when they deserialized their objects, and vice versa, so one has to choose one way, make one party happy, and the other one unhappy.

It turns out that the easiest way to deal with this issue was to simply let a JSON string be a string. No need for any special cases within string handling. If the parser sees a property that has a value that is enclosed in double quotes, it only needs to put it into a string, and done.

Dealing With The Issue Yourself

Okay, JSON.parse can't deal with dates, but you are still left with the issue of how you deal with the problem yourself.

You could, for example, come up with a very naive solution.

const deserialized = JSON.parse(serialized);
const fixedDeserialized = {
  ...deserialized,
  date: new Date(deserialized.date)
};
console.log(typeof fixedDeserialized.date);
// => prints object

But this solution doesn't look like a good one as it requires a lot of manual work and code. And now imagine doing this over and over again, for each and every object with a different structure than all other objects before. Doesn't sound like a great thing to do, does it?

It turns out that the creators of JSON.parse knew that there would be some cases where other developers would like to customize the parser's behavior. This is why the actual prototype of JSON.parse looks like this: JSON.parse(text[, reviver]).

text is the string that you want to parse, but what's that optional argument reviver? Well, it turns out that the description of reviver is as follows:

If a function, this prescribes how the value originally produced by parsing is transformed, before being returned.

It's a custom function, which enables you to write custom logic, and it has the following prototype: (key: string, value: any) => any.

As you might have guessed already, key is the property within the JSON object, and value is its actual value, and it turns out that this function is called as soon as the parser has already processed the property and its value. This means that you won't have to deal with low-level stuff like turning string digits into numbers and such. All of that has already been done by the parser when your function gets called.

An easy way to find out what the reviver actually does is to try it out with a very simple arrow function.

JSON.parse(serialized, (key, value) => {
  console.log(key, value);
  return value;
});
// => prints
// date 2020-12-03T10:29:47.259Z 
// id foo 
// "" {date: "2020-12-03T10:32:05.595Z", id: "foo"}

It seems that all properties and values are passed to the function, and lastly the whole object once again. This is some information you can now work with to implement a function which turns your date strings into real date objects again.

As a simple implementation, you can use a regex to determine whether a string contains an ISO date string and create an implementation which handles the actual deserialization automatically.

const isoDateRegex = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*))(?:Z|(\+|-)([\d|:]*))?$/;

function isIsoDate(value) {
  return isoDateRegex.exec(value);
}

function parseDate(ignored, value) {
  if (typeof value !== "string") {
    return value;
  }

  if (isIsoDate(value)) {
    return new Date(value);
  }

  return value;
}

As soon as you have this implementation in place, you can use it like your inline arrow function before.

const deserialized = JSON.parse(serialized, parseDate);
console.log(typeof deserialized.date);
// => prints object

And there you have it. Passing a reviver to JSON.parse is way less code every time you really need it, than to write a custom implementation with more lines of code every time.

Before you leave

If you like my content, visit me on Twitter, and perhaps you’ll like what you see!