getshaka-org / native-converter   0.9.0

Apache License 2.0 GitHub

Easily convert between Scala.js and native JavaScript

Scala versions: 3.x
Scala.js versions: 1.x

A Scala.js project that makes it easy to convert to and from Json and native JavaScript.

import scala.scalajs.js
import org.getshaka.nativeconverter.NativeConverter, fromJson, fromNative}

case class User(name: String, isAdmin: Boolean, age: Int) derives NativeConverter
val u = User("John Smith", true, 42)

// serialize
val json: String = u.toJson
val nativeJsObject: js.Any = u.toNative

// deserialize
val parsedUser: User = json.fromJson[User]
val parsedNativeUser: User = nativeJsObject.fromNative[User]

The primary goals are:

  1. Easy conversion from case classes and enums to Json Strings.
  2. Make interop with native JavaScript libraries easier.
  3. High performance and no dependencies.

Contents

Installing

This library requires Scala >= 3.1.0. After setting up a Scala.js project with SBT,

In /project/plugins.sbt add the latest sbt-dotty and Scala.js plugin:

addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.10.0")

Then in /build.sbt, set the scala version and add the native-converter dependency:

libraryDependencies ++= Seq(
  "org.getshaka" %%% "native-converter" % "0.8.0"
)

ScalaDoc

https://javadoc.io/doc/org.getshaka/native-converter_sjs1_3/latest/api/org/getshaka/nativeconverter/NativeConverter.html.

Built-In NativeConverters

Many built-in NativeConverters are already included.

Primitive Types

You can summon built-in NativeConverters for all the primitive types:

val i: Int = NativeConverter[Int].fromNative(JSON.parse("100"))

val nativeByte: js.Any = NativeConverter[Byte].toNative(127.toByte)

val s: String = NativeConverter[String]
  .fromJson(""" "hello world" """)

Char, Long, and Overriding the Defaults

Char and Long are always converted to String, since they cannot be represented directly in JavaScript:

// native String
val nativeLong = NativeConverter[Long].toNative(Long.MaxValue)

val parsedLong = NativeConverter[Long]
  .fromJson(s""" "${Long.MaxValue}" """)

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.

  given NativeConverter[Long] with
    extension (t: Long)
      def toNative: js.Any =
        if t > Int.MaxValue || t < Int.MinValue then t.toString
        else t.toInt.asInstanceOf[js.Any]

    def fromNative(ps: ParseState): Long =
      ps.json.asInstanceOf[Any] match
        case i: Int    => i
        case s: String => s.toLongOption.getOrElse(ps.fail("Long in a String"))
        case _         => ps.fail("Long")

// "123"
val smallLong: String = NativeConverter[Long].toJson(123L)

// """ "9223372036854775807" """.trim
val bigLong: String = NativeConverter[Long].toJson(Long.MaxValue)

Note that we must implement fromNative(ps: ParseState). The ParseState is used to build helpful error messages.

Functions

Functions can be converted between Scala.js and Native:

val helloWorld = (name: String) => "hello, " + name

val nativeFunc = NativeConverter[String => String].toNative(helloWorld)

// returns "hello, Ray"
nativeFunc.asInstanceOf[js.Dynamic]("Ray")

But remember, Javascript functions are not valid Json and will be not included in toJson output.

IArrays, Arrays, Iterables, Seqs, Sets, and Lists

These collections are serialized using JavaScript Arrays:

import scala.collection.{Seq, Set}

val seq = Seq(1, 2, 3)
val set = Set(1, 2, 3)

// "[1,2,3]"
val seqJson = NativeConverter[Seq[Int]].toJson(seq)

// "[1,2,3]"
val setJson = NativeConverter[Set[Int]].toJson(set)

Maps and EsConverters

Maps become JavaScript objects:

import scala.collection.Map
import scala.collection.mutable.HashMap

val map = HashMap("a" -> 1, "b" -> 2)

// """ {"a":1,"b":2} """.trim
val mapJson = NativeConverter[Map[String, Int]].toJson(map)

Only String keys are supported, since JSON requires String keys. If you'd rather convert to an ES 2016 Map, do the following:

import org.getshaka.nativeconverter.EsConverters.given

val map = HashMap(1 -> 2, 3 -> 4)

val nativeMap = NativeConverter[Map[Int, Int]].toNative(map)

// returns 4
nativeMap.asInstanceOf[js.Dynamic].get(3)

Converters are not yet implemented for many native ES types, please file an issue or PR if we're missing one you'd like.

Option

Option is serialized with null if None, and the converted value if Some.

val nc = NativeConverter[Option[Array[Int]]]
val some = Some(Array(1,2,3))

// "[1,2,3]"
val someJson = nc.toJson(some)

// None
val none = nc.fromJson("null")

Typeclass Derivation

Any Product or Sum type can derive a NativeConverter. Product types are serialized into objects with the parameter names as keys. Simple Sum types (ie, non-parameterized enums and sealed hierarchies) are serialized using their (short) type name. Other Sum types are serialized and deserialized using a @type property that equals the (short) type name.

This behavior closely matches Jackson and other popular libraries, in order to maximize compatibility.

You can for example redefine Option as a Scala 3 enum:

enum Opt[+T] derives NativeConverter:
  case Sm(x: T)
  case Nn

// """ {"@type":"Nn"} """.trim
val nnJson = Opt.Nn.toJson

// Opt.Sm(123L)
val sm = """ {"x":123,"@type":"Sm"} """.fromJson[Opt[Long]]

And of course, you can nest to any depth you wish:

// recommended but not required for X to derive NativeConverter
case class X(a: List[String]) 
case class Y(b: Option[X]) derives NativeConverter

val y = Y(Some(X(List())))
val yStr = """ {"b":{"a":[]}} """.trim

assertEquals(yStr, y.toJson)

assertEquals(y, yStr.fromJson[Y])

Cross Building

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. NativeConverter only works in JS, and will throw UnsupportedOperationException when used on the JVM.

Make sure to add the native-converter library dependency to both the JVM and JS projects, otherwise /shared won't compile.

Here is a sample cross-project you can clone: https://github.com/AugustNagro/native-converter-crossproject

Performance

The generated JavaScript code is very clean. 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.

Performance is improved by not using any internal AST, and by utilizing the natively-implemented JSON methods.

Thanks

It is safe to say that Scala 3 is very impressive. 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 provided advice on the conversion of Sum types.

License

https://www.apache.org/licenses/LICENSE-2.0