(De)serializing sets, maps and dates
I think we all sometimes need to serialize and deserialize Set
, Map
or Date
objects in JavaScript, but JSON.stringify and JSON.parse historically don’t handle them. Often, it’s a good idea to sit down and pick a good serialization library or data interchange format (MessagePack, Protocol Buffers, Avro), but sometimes we don’t want to bring dependencies for a relatively simple use-case.
For instance, I recently needed to store a relatively small state with Set
and Map
objects in localStorage
. So I decided to write a couple of helper functions that are capable of serializing/deserializing simple Set
, Map
and Date
objects without any dependencies.
In this article I’ll show how I did that.
Representation
Before we start actually serializing and deserializing custom objects, let’s first think how we could represent them in plain JSON. The idea I came up with is simple: we encode values as tuples with custom string markers as the first element and our custom representation of the value as the second. Some examples:
Then, when deserializing, we check if we’re looking at a tuple and check if its first element is a marker. If it is, we deserialize it into the object we need. Simple and easy.
Some downsides of this approach:
- It’s space inefficient.
- String markers may collide with some arbitrary strings.
- Not all data can be serialized/deserialized this way.
Adding some types
Now, that we have a representation, let’s add some types and structure. For each custom object we want to serialize and deserialize, we will have a “codec” object adhering to Codec<T, S>
interface that is generic over the input type T
and the output type S
.
We’ll also need a Mark
enum for string markers and a generic alias Serialized<M, S>
for our serialized tuple.
Serialize
Let’s take a look at serialization with JSON.stringify
. It accepts not only an object to convert to a JSON string, but also a replacer
function as a second parameter that can be used to alter the behavior of the serialization process:
Here the key
is the property being converted and value
is a property value, either raw or pre-transformed (i.e. serialized if it implements toJSON
method).
Set
Armed with this knowledge, let’s implement Set
serialization and actually some part of deserialization. We first define a codec that will contain the actual serialization and deserialization functions:
After that let’s wrap JSON.stringify
in a function and pass a replacer function, where we check if the value is a Set
object and use our SetCodec
to serialize it:
Map
Essentially the same principle as with Set
. We first define a codec:
And then add a case to the replacer function to actually handle Map
objects:
Date
At last, let’s handle Date
objects. As we did with Set
and Map
we first define a codec:
We also need to slightly change the replacer function to be a regular function instead of an arrow function, and check this[key]
instead of value
to properly handle Date
objects:
Why? Replacer function is called recursively, where:
this
is the current node of the object.key
is the property being converted.value
is a pre-transformed property value, being a string when original was aDate
.
Hence using this[key]
, which gives us a raw Date
rather than value
, which gives us already serialized string.
Also note that to make this code typecheck in strict mode (which implies noImplicitAny
and noImplicitThis
to be true
), you need this
to be explicitly typed (with any
in this case). How do you do that in functions? Well, TypeScript since 2.x allows to specify this
parameter with desired type:
This only works with function
declarations though, not arrow functions, because the latter don’t have their own this
. See the documentation for additional information.
Deserialize
Now that we have a working serializer, let’s write a deserializer. Similarly to JSON.stringify
, it accepts a reviver
function as a second parameter that can be used to alter the behavior of the deserialization process:
As with serialize
, we wrap JSON.parse
in a function, although this time making it generic over the output type T
, and pass a reviver function:
Right now this does nothing with our special tuples. We need somehow to detect them and deserialize. For this we’ll need to access the raw value and check if it’s marked:
And that’s it! The complete code can be found here.