Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

Scala.jsを介したJavaScriptのObjectとの相互運用について勉強した

Scala.jsはScalaからJavaScriptのトランスパイラであり、執筆時点でバージョン1.13.2を記録しており、活発に開発が行われている。加えて、これを補助するコンパイラプラグインであるScalablyTypedによるTypeScriptの型定義からScalaの型定義への転写によって、TypeScriptで記述されたライブラリをそのままScala.jsから呼び出す方法が整いつつある。しかしながらJavaScript/TypeScriptにおけるobjectと、Scalaにおけるcase class / traitとの間には、変換する上で押さえておかなければならないポイントがいくつかある。この記事では、よくあるpitfallを明らかにし、入門者がスムーズにScala.jsでデータ交換ができるようにする。

この記事を読むべき人

  • Scala.jsでJavaScript/TypeScriptとデータを相互運用したい人
  • JS/TS側のobjectをScala側で受け取りたい人

バージョン

  • Scala 3.3.0
  • Scala.js 1.13.2

また、以下説明するコードでは以下のimportを前提とする:

import scala.scalajs.js

モチベーション

筆者はObsidianのプラグインをScala.jsで開発するというプロジェクトを趣味でやっている。プロジェクトといっても数コミットしかしておらず、とりあえずObsidianでHello Worldが動いたというレベルだが、あとはObsidian側が提供するTypeScriptライブラリobsidianを適切に呼び出せばプラグインとして動作させられるはずだ。

しかしながら、obsidianがTSでの利用を前提においている以上、objectのやりとりが頻繁に発生する。例えばプラグイン設定を読み出すときは、obsidianobjectとしてデータを渡してくるが、Scala側ではcase classtraitとして扱いたい。また逆にこれを保存するときは、Scala側のcase classtraitをうまくobjectへと変換しなければならない。

筆者の今の知識ではこれが難しいと感じたため、調べたり勉強したメモを残すことにした。

Scala.jsでobjectを扱う

Scala.jsにおいて、objectscala.scalajs.js.Objectという型で表現される。

JavaScript types - Scala.js を参考にすると、Scala.js側からjs.Objectを作成する方法がわかる:

まずはcase classなどを使わずに直接objectを作成する方法。

js.Dynamic.literal(foo = 42, buzz = "bar")
// => { foo: 42, buzz: "bar" }: js.Object
// もしくは
js.Dynamic.literal("foo" -> 42, "buzz" -> "bar")

traitによるインターフェイスを設定する

js.objectはそのままではScala側に何の型情報ももたらさないため、実用上はどのようなフィールドがあるかを決めておきたい。js.Objectを使ったtraitを定義することで、特定のフィールドが生えたobjectを定義できる:

@js.native
trait Foo extends js.Object {
  val foo: Int = js.native
  val bar: String = js.native
}

実際にこの型のオブジェクトをインスタンス化するには、コンパニオンオブジェクトを使ってコンストラクタを実装してやればよい:

// companion object for Foo
object Foo {
  def apply(foo: Int, bar: String): Foo =
    js.Dynamic.literal(foo = foo, bar = bar).asInstanceOf[Foo]
}

いったんjs.Dynamic.literalを使ってオブジェクトを生成し、これをさらに望む型へと強制的に型変換している。

ざっくり言うとScala.jsにおけるjs.Objectはオブジェクト全てを包含するAnyのようなものなので、ここから直接フィールドを取り出すことができない(そのような知識を持たない)。

objectをcopyする

objectを扱っていると、一部フィールドをいじりたいことがある。そしてScalaは基本的にイミュータブルなので、copyしたほうが都合が良い。

assignする方法

Scala.jsのjs.Objectには、他のScalaのクラスがそうであるようにcopyメソッドが生えているわけではないので、値を修正しつつ複製したいときはjs.Object.assignを利用する必要がある。ただし、assignはターゲットとなるobjectを上書きするので、一度createで複製する必要がある:

val old = Foo(foo = 42, bar = "buzz")
val tgt = js.Object.create(old)
js.Object.assign(tgt, old, js.Dynamic.literal(foo = 666))
// => { foo: 666, bar: "buzz" }

ほぼ等価なJavaScriptをいじっているという感じで、あまりスマートではないが、このようなレベルの操作があること自体はありがたい。

等価なcase classを生やす方法

これでは大変なので、js.Objectを反映したcase classを用意して、相互変換できるようにしておくと便利だ:

case class FooScala(val foo: Int, val bar: String) {
  def toJS: Foo = Foo(foo = foo, bar = bar)
}
object FooScala {
  def fromJS(foo: Foo): FooScala = FooScala(foo = foo.foo, bar = foo.bar)
}
val old = Foo(foo = 42, bar = "buzz")
val oldScala = FooScala.fromJS(old)
val tgt = oldScala.copy(foo = 666).toJS
// => { foo: 666, bar: "buzz" }

普段の作業はcase class側で行い、ライブラリに渡すときに変換すればよい。

これらの実装が自動生成されると嬉しいが、js.Objectが単なるデータであるという保証はないので、何も考えずに自動生成できるわけではないのだろう。頑張ってコンパイル時マクロを書いたら面白いものが作れそうなので、誰か作ってほしい(他力本願)。

@JSExportAllで定義する

型情報をTypeScriptが持っているわけではなく、Scala側で決めてもよいときは、@JSExportAllを使うことでcase classobjectが変換できるようになる。

@JSExportAll
case class Foo(foo: Int, bar: String)
val jsFoo = Foo(42, "buzz").asInstanceOf[js.Object]

しかしながら、JS側が生成したオブジェクトを直接キャストすることはできないようだ:

val jsFoo = ...
jsFoo.asInstanceOf[Foo] // Runtime Error!

JavaScriptとScala.jsとの関係

覚えておきたいのが、JavaScript(ECMAScript)のobject自体は動的な型しか持たないということだ。このため、静的型付けの言語であるScala上ではjs.objectは雲のような存在となる。直接フィールドが型として表れないため、あらかじめユーザが型を用意してやり、キャストしなければならない。

JavaScriptに静的な型を導入するとTypeScriptになる。TypeScriptの型はScalablyTypedというライブラリを使うことでほぼシームレスにScala.jsのコードに変換されるが、ライブラリが素のObjectをよこしてくることがある。具体的には、ObsidianのAPIは設定を保存/読み出すときにObjectを返す。設定の型がどうなるかは完全にプラグインの実装者依存なためである。ライブラリがTypeScriptで提供される場合は、ScalablyTypedを使って型情報を導出してもらうとよいだろう。ScalablyTypedについては、この記事では触れない。またそのうち書くと思う。

scalablytyped.org

各パーツの責務

この記事をまとめると、各パーツは以下のように振る舞うということを覚えておけばよさそうだ:

  • JavaScript: Scala.jsが生成したコードが実際に動作する場。JavaScriptで書かれたライブラリは直接ここで動作する。
  • Scala.js: ScalaのコードをJavaScriptにトランスパイルする。動的に動作するランタイムはほぼないはず*1。JavaScriptのAPIをコールする方法を提供する。
  • ScalablyTyped: TypeSciptの型定義ファイルをScala.js用に変換する。

実用上覚えておくと良いことは、以下のようなことだ:

  • JavaScriptで扱いたいクラスは、js.nativeを使ってtraitを定義することでファサードを定義できる
  • js.ObjectasInstanceOfでキャストすることで、そのファサードを介してフィールドにアクセスできるようになる。
  • ファサードはScalaのクラスそのものではなく、あくまでファサードなので、case classのように扱いたかったら別途case classを定義する。
  • 既に型定義がTypeScriptに用意されているなら、ScalablyTypedを使う。

参考文献

www.scala-js.org

www.scala-js.org

www.scala-js.org

stackoverflow.com

この記事はバージョン0台のScala.jsを前提に書かれており古く、動作しないため注意。コメントも読むこと。

*1:違ってたら教えて!!

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?