Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

scala.js — getting complex objects from JavaScript

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])

1st attempt

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

2nd attempt

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 :(

3rd attempt

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.

Conclusion so far

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?

like image 426
Maguro Avatar asked Mar 14 '16 15:03

Maguro


2 Answers

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)
    }
  }
}
like image 122
sjrd Avatar answered Sep 28 '22 01:09

sjrd


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).

like image 25
Justin du Coeur Avatar answered Sep 28 '22 01:09

Justin du Coeur