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
のやりとりが頻繁に発生する。例えばプラグイン設定を読み出すときは、obsidian
はobject
としてデータを渡してくるが、Scala側ではcase class
かtrait
として扱いたい。また逆にこれを保存するときは、Scala側のcase class
かtrait
をうまくobject
へと変換しなければならない。
筆者の今の知識ではこれが難しいと感じたため、調べたり勉強したメモを残すことにした。
Scala.jsでobject
を扱う
Scala.jsにおいて、object
はscala.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 class
とobject
が変換できるようになる。
@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については、この記事では触れない。またそのうち書くと思う。
各パーツの責務
この記事をまとめると、各パーツは以下のように振る舞うということを覚えておけばよさそうだ:
- JavaScript: Scala.jsが生成したコードが実際に動作する場。JavaScriptで書かれたライブラリは直接ここで動作する。
- Scala.js: ScalaのコードをJavaScriptにトランスパイルする。動的に動作するランタイムはほぼないはず*1。JavaScriptのAPIをコールする方法を提供する。
- ScalablyTyped: TypeSciptの型定義ファイルをScala.js用に変換する。
実用上覚えておくと良いことは、以下のようなことだ:
- JavaScriptで扱いたいクラスは、
js.native
を使ってtrait
を定義することでファサードを定義できる js.Object
をasInstanceOf
でキャストすることで、そのファサードを介してフィールドにアクセスできるようになる。- ファサードはScalaのクラスそのものではなく、あくまでファサードなので、
case class
のように扱いたかったら別途case class
を定義する。 - 既に型定義がTypeScriptに用意されているなら、ScalablyTypedを使う。
参考文献
この記事はバージョン0台のScala.jsを前提に書かれており古く、動作しないため注意。コメントも読むこと。
*1:違ってたら教えて!!