I'm trying scala.js and I must say that it's completely impressed! However, I try to introduce it into our production little-by-little, working side-by-side with existing JavaScript code. One thing I'm struggling with is passing complex structures from JS to Scala. For example, I have ready-made JS object that I've got from the other JS module:
h = {
"someInt": 123,
"someStr": "hello",
"someArray": [
{"name": "a book", "price": 123},
{"name": "a newspaper", "price": 456}
],
"someMap": {
"Knuth": {
"name": "The Art of Computer Programming",
"price": 789
},
"Gang of Four": {
"name": "Design Patterns: Blah-blah",
"price": 1234
}
}
}
It
has some ints, some strings (all these elements have fixed key names!), some arrays in it (which in turn has some more objects in it) and some maps (which map arbitrary string keys into more objects). Everything is optional and might be missing. Obviously, it's just a made-up example, real-life objects are much more complex, but all the basics are up here. I already have the corresponding class hierarchy in Scala which looks something like that:
case class MegaObject(
someInt: Option[Int],
someStr: Option[String],
someArray: Option[Seq[Item]],
someMap: Option[Map[String, Item]]
)
case class Item(name: Option[String], price: Option[Int])
My first try was to a naïve attempt to just use receiver types as is:
@JSExport
def try1(src: MegaObject): Unit = {
Console.println(src)
Console.println(src.someInt)
Console.println(src.someStr)
}
and it, obviously, fails with:
An undefined behavior was detected: [object Object] is not an instance of my.package.MainJs$MegaObject
My second idea was receiving this object as js.Dictionary[String]
and then doing lots of heavy typechecking & typecasting. First we'll define some helper methods to parse regular strings and integers from JS object:
def getOptStr(obj: js.Dictionary[String], key: String): Option[String] = {
if (obj.contains(key)) {
Some(obj(key))
} else {
None
}
}
def getOptInt(obj: js.Dictionary[String], key: String): Option[Int] = {
if (obj.contains(key)) {
Some(obj(key).asInstanceOf[Int])
} else {
None
}
}
Then we'll use them to parse an Item
object from the same source:
def parseItem(src: js.Dictionary[String]): Item = {
val name = getOptStr(src, "name")
val price = getOptInt(src, "price")
Item(name, price)
}
And then, all together, to parse whole MegaObject
:
@JSExport
def try2(src: js.Dictionary[String]): Unit = {
Console.println(src)
val someInt = getOptInt(src, "someInt")
val someStr = getOptStr(src, "someStr")
val someArray: Option[Seq[Item]] = if (src.contains("someArray")) {
Some(src("someArray").asInstanceOf[js.Array[js.Dictionary[String]]].map { item =>
parseItem(item)
})
} else {
None
}
val someMap: Option[Map[String, Item]] = if (src.contains("someMap")) {
val m = src("someMap").asInstanceOf[js.Dictionary[String]]
val r = m.keys.map { mapKey =>
val mapVal = m(mapKey).asInstanceOf[js.Dictionary[String]]
val item = parseItem(mapVal)
mapKey -> item
}.toMap
Some(r)
} else {
None
}
val result = MegaObject(someInt, someStr, someArray, someMap)
Console.println(result)
}
It, well, works, but it is really ugly. That's lots of code, lots of repetitions. It can probably refactored to extract array parsing and map parsing into something saner, but it still feels bad :(
Tried the @ScalaJSDefined
annotation to create something along the lines of "facade" class, as described in documentation:
@ScalaJSDefined
class JSMegaObject(
val someInt: js.Object,
val someStr: js.Object,
val someArray: js.Object,
val someMap: js.Object
) extends js.Object
Just printing it out kind of works:
@JSExport
def try3(src: JSMegaObject): Unit = {
Console.println(src)
Console.println(src.someInt)
Console.println(src.someStr)
Console.println(src.someArray)
Console.println(src.someMap)
}
However, as soon as I'm trying to add a method to JSMegaObject "facade" that will convert it to its proper Scala counterpart (even a fake one like this):
@ScalaJSDefined
class JSMegaObject(
val someInt: js.Object,
val someStr: js.Object,
val someArray: js.Object,
val someMap: js.Object
) extends js.Object {
def toScala: MegaObject = {
MegaObject(None, None, None, None)
}
}
trying to call it fails with:
An undefined behavior was detected: undefined is not an instance of my.package.MainJs$MegaObject
... which kind of really reminds me of attempt #1.
Obviously, one can still do all the typecasting in the main method:
@JSExport
def try3real(src: JSMegaObject): Unit = {
val someInt = if (src.someInt == js.undefined) {
None
} else {
Some(src.someInt.asInstanceOf[Int])
}
val someStr = if (src.someStr == js.undefined) {
None
} else {
Some(src.someStr.asInstanceOf[String])
}
// Think of some way to access maps and arrays here
val r = MegaObject(someInt, someStr, None, None)
Console.println(r)
}
However, it quickly becomes just as ugly as attempt #2.
So, I'm kind of frustrated. Attempts #2 and #3 do work, but it really feels that I'm missing something and it shouldn't be that ugly, uncomfortable, and require to write tons of JS-to-Scala types converter code just to access the fields of an incoming JS object. What is the better way to do it?
Your attempt #4 is close, but not quite there. What you want is not a Scala.js-defined JS class. You want an actual facade trait. Then you can "pimp" its conversion to your Scala class in its companion object. You must also be careful to always use js.UndefOr
for optional fields.
@ScalaJSDefined
trait JSMegaObject extends js.Object {
val someInt: js.UndefOr[Int]
val someStr: js.UndefOr[String],
val someArray: js.UndefOr[js.Array[JSItem]],
val someMap: js.UndefOr[js.Dictionary[JSItem]]
}
object JSMegaObject {
implicit class JSMegaObjectOps(val self: JSMegaObject) extends AnyVal {
def toMegaObject: MegaObject = {
MegaObject(
self.someInt.toOption,
self.someStr.toOption,
self.someArray.toOption.map(_.map(_.toItem)),
self.someMap.toOption.map(_.mapValues(_.toItem)))
}
}
}
@ScalaJSDefined
trait JSItem extends js.Object {
val name: js.UndefOr[String]
val price: js.UndefOr[Int]
}
object JSItem {
implicit class JSItemOps(val self: JSItem) extends AnyVal {
def toItem: Item = {
Item(
self.name.toOption,
self.price.toOption)
}
}
}
Getting these objects from JavaScript to Scala is actually fairly easy. You were on the right track, but needed a little bit more -- the trick is that, for cases like this, you need to use js.UndefOr[T]
instead of Option[T]
, and define it as a facade. UndefOr
is a Scala.js type that means exactly "this is either a T or undefined", and is mainly intended for interaction cases like this. It includes a .toOption
method, so it's easy to interface with Scala code. You can then simply cast the object you get from JavaScript to this facade type, and everything ought to work.
Creating one of these JSMegaObjects from Scala takes a bit more work. For cases like this, where you are trying to create a complex structure with lots of fields that might or might not exist, we have JSOptionBuilder
. It's named like that because it was written for the big "options" objects that are common in jQuery, but it's not jQuery-specific. You can find it in the jsext library, and documentation can be found on the front page there.
You can also see a moderately complex fully-worked example in the JQueryAjaxSettings class in jquery-facade. That shows both the JQueryAjaxSettings trait (the facade for the JavaScript object) and the JQueryAjaxSettingsBuilder (which lets you construct one from scratch in Scala).
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With