ChatWork Creator's Note

ビジネスチャット「チャットワーク」のエンジニアとデザイナーのブログです。

ScalaJS for TypeScripter

こんにちは。@kyo_agoです。

だいぶScalaJSでの開発知見が溜まってきたので共有したいと思います。

d.tsから.scalaへの変換

TypeScriptを使っていると真っ先に思い浮かぶのが「型定義は?」ということだと思います。

これに関してはScalaJSでもTypeScriptと同じようにScala側のコードからJS側のコードを呼び出す際に型定義が必要になります。

基本的には TypeScript Importer for Scala.js を使うことでd.tsから.scalaのコードを出力できますが、一部の機能は変換できないためエラーになったり定義が出力されなかったりします。
Type Definition Importer for Scala.js でWeb上から試すことができますが、必ずしも最新版でない可能性があるので注意してください)

サポート範囲に関しては scala-js-ts-importer/samples をみてもらう方がわかりやすいかもしれませんが、特に以下の点に注意してください。

  • Advanced Types · TypeScript に書かれている内容に関してはかなりサポートされていません。
  • ScalaJSで表現できる範囲であってもscala-js-ts-importerがサポートしていないため出力が省略される記法も多いです。

変換結果に関しては必ず確認することをおすすめします。

npm経由でinstallしたライブラリの使い方

まず、JS側のライブラリの読み込み方によって定義の仕方が若干変わります。

基本的には Write facade types for JavaScript APIs - Scala.js ここに書かれているとおりですが、変換結果に自信が持てない場合、事前にjs側で読み込んでおいてScala側からは呼び出すだけのほうが簡単かもしれません。

// JS側で定義されたclass Hogeのfacade
@js.native
// window object上に定義された名前は以下で定義するのでここではどんな名前でも良い
trait HogeClassBase extends js.Object {
  def huga(): String = js.native
}

// Globals object(ブラウザ環境の場合、window object)の型定義
@js.native
@JSGlobalScope
object Globals extends js.Object {
  // window.HogeClassの定義
  val HogeClass: HogeClassBase = js.native
}

object TutorialApp {
  def main(args: Array[String]): Unit = {
    // window object経由でJS側のHogeClassを呼び出す
    val hoge = new Globals.HogeClass()
    println(hoge.huga())
  }
}

ScalaJS側の型定義の書き方

基本的な書き方は Write facade types for JavaScript APIs - Scala.jsNon-native JS types (aka Scala.js-defined JS types) - Scala.js をみてもらうほうがわかりやすいと思いますが、ここでは実際記述していて困った部分を書いておこうと思います。

Union type

TypeScriptで言うところの|ですが、これはScalaJSでも同じように書けます。

import scala.scalajs.js.|

@js.native
trait HogeClassBase extends js.Object {
  // 戻り値はStringかIntのいずれか
  def huga(): String | Int = js.native
}

ただ、これはScalaの型定義としてはjs.|[String, Int]という形で定義されるため、そのままではStringとしてもIntとしても使うことができません。

これに関しては戻り値を.isInstanceOf[String]等で分岐した後.asInstanceOf[String]で変換することで望みの型で使うことができるようになります。
.asInstanceOfでの変換はJSで実行時の方が違うと実行時エラーになるため必ず先に.isInstanceOfで判定を行ってください)

import scala.scalajs.js.|

object Hoge {
  def main(huga: String | Int) {
    if (huga.isInstanceOf[String]) {
      println(huga.asInstanceOf[String])
    }
  }
}

また、.asInstanceOf.isInstanceOfの引数の型が元の定義と違っていてもコンパイルは成功するので注意してください

import scala.scalajs.js.|

object Hoge {
  def main(huga: String | js.Object) {
    // 実行はされないがコンパイルは通る
    if (huga.isInstanceOf[Int]) {
      println(huga.asInstanceOf[String])
    }
    // 警告なくコンパイルできて実行時エラーになる
    if (huga.isInstanceOf[String]) {
      println(huga.asInstanceOf[Int])
    }
  }
}

もしmatchを使う場合、一旦Anyに変換することで記述することができます。

import scala.scalajs.js.|

object Hoge {
  def main(hoge: String | Int) {
    (hoge: Any) match {
      case huga: String => println(huga)
      case huga: Int => println(huga)
    }
  }
}

他にも.toStringを使うこともできます。

import scala.scalajs.js.|

object Hoge {
  def main(hoge: String | Int) {
    println(huga.toString)
  }
}

scala.scalajs.js.|の扱いに関してはStack Overflowの質問に対する作者の返信も参照してみてください。

Nullable types

基本的にはOption型で定義すれば問題ありません。

注意点として、nullはNoneと判定されますが、undefinedはSomeとして判定されます。

// nullが帰るのでnoneが出力される
Option(js.Dynamic.global.document.getElementById("")) match {
  case Some(_) => println("some")
  case None => println("none")
}

// undefinedはsomeが出力される
Option(js.Dynamic.global.undefined) match {
  case Some(_) => println("some")
  case None => println("none")
}

そのため、以下のようなコードは実行時エラーになる可能性があるため注意してください。

// window.Hogeがある場合実行したい
Option(js.Dynamic.global.Hoge) match {
  // window.Hogeが存在しない場合でも実行される(実行時エラー)
  case Some(_) =>js.Dynamic.global.Hoge.asInstanceOf[js.Function0[Unit]]()
  case None =>
}

この場合、以下のように判定してください。

// hasOwnPropertyで判定する
if (js.Dynamic.global.hasOwnProperty("Hoge")) {
  js.Dynamic.global.Hoge.asInstanceOf[Hoge]()
}

String / Numeric Literal Types, Index types

TypeScriptで言うところの以下のような定義です。

// String Literal Types
type Easing = "ease-in" | "ease-out" | "ease-in-out";

// Numeric Literal Types
function rollDie(): 1 | 2 | 3 | 4 | 5 | 6 {}

// Index types
interface Event {
    click: string;
    hover: number;
}
function addEvent<K keyof Event>(name: K): Event[K] {}

これは現状のところScalaJSではサポートされていません。
(StringやIntとして定義することになります)

これに関してはScala側の制限でもあるため、ScalaJSでのサポートは期待できないかもしれません。

ここで問題になるのがEventEmitter等の型定義です。

例えば、ScalaJSの型定義ではdocument.addEventListenerの第一引数がStringになっており、任意のevent nameを指定できることになっています。
(callbackの引数は型引数で定義できる)

この問題に関して、個人的にはjsのAPIを呼び出すScala層を置いて、Scala側のコードで制限をかけています。

@js.native
@JSGlobal
class EventEmitter extends js.Object {
  def on(name: String, callback: js.Function): Unit = js.native
}

class Event() {
  private val ee = new EventEmitter()
  def onClick(callback: js.Function) {
    ee.on("click", callback)
  }
  // 他に呼び出し可能なeventを定義していく
}

debugger statementの書き方

JSではdebuggerを書くことでDevToolsのコード実行を停止させてデバッグすることが多いですが、ScalaJSでは以下のように記述することで同じようにコード実行を停止させることができます。

js.special.debugger()

console.logの書き方

ScalaのprintlnはScalaJSでも使用できますが、出力がStringに変換されるためDevToolsのconsoleで確認するには情報が不足します。
(Objectを展開したりできない)

JSでconsoleにlogを出すにはconsole.logを使用しますが、ScalaJSでは以下のような記述でconsole.logを呼び出すことができます。

js.Dynamic.global.console.log("aaaa")

js.Dynamic.globalの記述がちょっと長いですが、以下のような記述で省略することもできます。

import js.Dynamic.{ global => g }
g.console.log("aaaa")

Object literalの書き方

JS側のAPI等でJSのObject literalが必要な場合、Scala側からは以下のように記述することができます。

val obj = js.Dynamic.literal(
  hoge = "hoge",
  huga = 111
)

Object literalのpropertyを参照するには先にtraitを記述して変換します。

@js.native
trait HogeObj extends js.Object {
  val hoge: String = js.native
  val huga: Int = js.native
}

// 型が一致していなくてもコンパイルは通りますが、実行時にエラーになるため注意してください。
val obj: HogeObj = js.Dynamic.literal(
  hoge = "hoge",
  huga = 111
)
println(obj.hoge) // "hoge"

trait内の定義をvarにするとpropertyを上書きできます。

@js.native
trait HogeObj extends js.Object {
  var hoge: String = js.native
  val huga: Int = js.native
}

val obj: HogeObj = js.Dynamic.literal(
  hoge = "hoge",
  huga = 111
)
obj.hoge = "bar"
// hugaはvalなのでコンパイル時にエラーになります。
obj.huga = 222

Object literalの展開方法

JS側のAPIから値を受け取った場合、Scala側からはそのままではアクセス出来ないため、変換する必要があります。

これは基本的に.asInstanceOfで変換すればいいのですが、「propertyの有無で型を分けたい」という場合には困ることになります。
.asInstanceOfでいきなり変換すると実行時エラーになる可能性がある)

その場合以下のように変換することで安全に扱うことができます。

@js.native
trait Hoge extends js.Object {
  // hogeは存在しない可能性があるので、js.UndefOrでくるんだ上でjs.undefinedで定義する
  val hoge: js.UndefOr[String] = js.undefined
}

// messageはhogeがなくても実行時エラーにならない
def Hoge(message: Hoge) {
  // js.UndefOrの持つtoOptionを使ってアクセスする
  message.hoge.toOption.foreach(println)
}

まとめ

色々問題点を上げていますが、現在でも十分実用的なので「Scalaでフロントエンドを書きたい」という場合には検討してみてください。