Fast JSON in Scala 3 with Typeclass Derivation :: Feb 15, 2021
What is the fastest way to parse & serialize JSON in Scala? Could it be Circe, BooPickle, Play Json, or maybe uPickle?
What if I told you the fastest Scala JSON library is already on your computer, implemented with native code, and updated regularly with improvements? Yes, I’m talking about the JSON object in Node.js and your browser. Sorry if you feel tricked! But Scala is a multi-platform language; JavaScript is just as much a Scala runtime as the JVM. JSON
is among the best performing parsers of ANY language, including vectorized C++ libraries. The best ‘Scala’ (and Java) library benchmarked is ‘built for invasive software composition’, and comes with its own ‘DSL platform compiler’. JSON
is simply the best.
Ok, so let’s create a Scala.js project, write a case class, and serialize it to JSON!
Unfortunately for us, JSON.stringify takes only native JavaScript parameters, ie, objects inheriting scala.scalajs.js.Any
. As specified in the interoperability guide, we can make a native class by extending js.Object:
Not again! As the compiler tells us, it is forbidden for case classes to extend js.Object. Why is that? It has to do with the semantic difference between Scala.js objects and native JavaScript objects. When you create an object like User("John Smith", true, 42)
for example, it does not automatically become
Instead, after sbt fullLinkJS
(which runs the Google Closure JavaScript Optimizer), the object properties may very well be ‘a’, ‘b’, and ‘c’. Or, the object could be inlined & removed entirely. This feature is why Scala.js’s performance is almost always better than hand-written JavaScript.
It also means that to interop with native JavaScript libraries like JSON
, you must give up Scala features like destructuring and pattern matching. Or, create native versions for every class and manually convert:
I like to be DRY, and Scala 3 gives us the power to achieve this with Typeclass derivation. Take a look:
That’s it, no manual conversion necessary.
A Typeclass lets you add behavior without inheritance. Instead of extending a class or implementing an interface, you create a Typeclass instance that operates on that type, and pass it around. Typeclasses let you add features to types you don’t control, aka Retroactive Polymorphism.
In Java, defining, instantiating, and passing around Typeclass instances would be inconvenient, so people extend
instead. But Scala 3 makes it easy. When you write case class User(..) derives NativeConverter
, the scala compiler calls method NativeConverter::derived
, which generates a given
instance in User’s companion object. When you summon a NativeConverter for User, either with summon[NativeConverter[User]]
or just NativeConverter[User]
via the 0-arg apply
helper method in NativeConverter
, the same instance is returned.
The implementation of NativeConverter is about 400 lines of 0-dependency Scala, and is NOT A MACRO! Here’s the contract of the NativeConverter Typeclass:
You can summon built-in NativeConverters for all the primitive types:
Char and Long are always converted to String, since they cannot be represented directly in JavaScript:
If you want to change this behavior for Long, implement a given
instance of NativeConverter[Long]. The example below uses String for conversion only when the Long is bigger than Int.
Functions can be converted between Scala.js and Native:
Arrays, Iterables, Seqs, Sets, Lists, and Buffers are serialized using JavaScript Arrays:
Maps become JavaScript objects:
Only String keys are supported, since JSON requires String keys. If you’d rather convert to an ES 2016 Map, do the following:
I haven’t implemented converters for all the native types, but if you ask me or make a pull request for one that’s missing I will merge it.
Option is serialized using a 1-element array if Some, or [] if None:
Any Product or Sum type (like case classes and enums) can derive a NativeConverter. If the Product is a Singleton (ie, having no parameters) then the type name or productPrefix is used. Otherwise, an object is created using the parameter names as keys.
You can for example redefine Option as a Scala 3 enum:
And of course, you can nest to any depth you wish:
If Cross Building your Scala project you can use one language for both frontend and backend development. Sub-project /jvm
will have your JVM sources, /js
your JavaScript, and in /shared
you can define all of your validations and request/response DTOs once. In the /shared
project you do not want to depend on NativeConverter
, since that would introduce a dependency on Scala.js in your /jvm
project. So instead of writing derives NativeConverter
on your case classes, create an object in /client
that holds the derived converters:
I’ve made a sample cross-project you can clone: https://github.com/AugustNagro/native-converter-crossproject
But what about performance, surely making your own js.Object subclasses is faster?
Nope, derived NativeDecoders are 2x faster, even for simple cases like User("John Smith", true, 42)
:
See these findings for more info.
The generated JavaScript code is very clean. I was amazed how nice it is when looking at sbt fastLinkJS
output. This is all possible because of Scala 3’s inline
keyword, and powerful type-level programming capabilities. That’s right.. no Macros used whatsoever! The derives
keyword on type T causes the NativeConverter Typeclass to be auto-generated in T’s companion object. Only once, and when first requested.
It is safe to say that I am very impressed with Scala 3. And a big thank you to Sébastien Doeraene and Tobias Schlatter, who are first-rate maintainers of Scala.js, as well as Jamie Thompson who gave me advice on the conversion of Sum types.
The NativeConverter library is free on github here: https://github.com/AugustNagro/native-converter