Lambdaカクテル

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

Invite link for Scalaわいわいランド

TypeScriptのvoidをasync voidとして継承できる / ScalablyTypedによるTypeScriptライブラリとScala.jsとの相互運用で、async functionがうまく変換されない問題

困っているので、思考の整理を兼ねてメモしておく。各項目がまちがっていたらおしえてください。

動機

自分はObsidianというテキストエディタを愛用しているのだが、愛が高じてプラグインを書きたくなってきた。プラグインはTypeScript(JS)で書くことができ、そのための型定義ライブラリも用意されている:

www.npmjs.com

あとは、ドキュメントに従って特定のクラスを継承し、これをexport defaultすると、Obsidianで読み込めるようになる。

Scala.js

Scalaのすごいライブラリ(というかビルド基盤)として、Scala.jsというのがある:

www.scala-js.org

これを使うと、Scalaのコードを謎のテクノロジーでjavascriptにコンパイルしてくれる。しかも、普通にちゃんと動くので驚きだ。

そこで、今回はScalaでObsidianのプラグインを書こうとした。

ScalablyTyped

さて、当たり前だがObsidianのライブラリはTypeScript版しか提供されていない。そこで、第二のテクノロジーであるScalablyTypedを利用する。

scalablytyped.org

このコンパイラプラグインは、謎のすごいテクノロジーによってTypeScript型定義ファイル(d.ts)をScalaのファサードに変換する。内部的にはScala.jsによってネイティブのJSのコードが呼び出されるように変換されるので、使う側からは何も考えずにScalaを呼び出しているような感覚でTSのライブラリを呼び出せる。

ScalablyTypedにおけるvoid

ScalablyTypedは、void型をUnitに変換する。

returnVoid(): void { ... }
def returnVoid(): Unit

ScalablyTypedにおけるasync

ScalablyTypedは、async functionをちゃんと変換してくれる。

async f(): string { ... }

上掲のようなTypeScriptのメソッドがある場合、以下のような型定義に変換される:

def f(): Promise[String]

PromiseはScala.jsが用意している、TypeScript上のPromiseを表現するラッパーで、これはそのままScalaのFutureに相互変換できるようになっている*1。したがって、以下のものと等価に扱える:

def f(): Future[String]

TypeScriptにおけるasync void

TypeScriptでは、asyncをつけた関数(メソッド)は、そのボディがPromiseを返さなくても、Promiseで包んでくれる:

async f() { return "foo" } // => Promise<String>になる

TypeScriptにおけるvoid methodのasynchronousな継承

TypeScriptでは、以下のような継承は可能なようだ:

class VoiComponent {
  load(): void {}
}

class VoiMyPl extends VoiComponent {
  override async load(): Promise<void> {}
}

継承した子が、親のvoidなメソッドをasyncキーワードによってPromise<void>に変換している。これはコンパイルが通っている。

他方、このような継承は不可能なようだ:

class NumComponent {
  load(): number {return 1}
}

class NuMMyPl extends NumComponent {
  override async load(): Promise<number> {return 1} // does not compile
}
Property 'load' in type 'NuMMyPl' is not assignable to the same property in base type 'NumComponent'.
  Type '() => Promise<number>' is not assignable to type '() => number'.
    Type 'Promise<number>' is not assignable to type 'number'.

ScalaにおけるUnitのasynchronousな継承

Scalaではこのようなことはできない:

import scala.concurrent.Future

class A {
  def f(): Unit = ???
}
class B extends A {
  override def f(): Future[Unit] = ??? // does not compile
}
error overriding method f in class A of type (): Unit;
  method f of type (): concurrent.Future[Unit] has incompatible type

Obsidianのプラグインにおける継承

Obsidianでは、ちょうど先述した例に該当するようなコードが発生している。ライブラリにあるonload(): voidが、これを継承したプラグインではasync onload()として使われているのだ。

ライブラリの記述をScalablyTypedはonload(): Unitに変換してしまう一方、使う局面ではどうしてもFutureでないとうまく動作しないようになっている。他方、勝手にシグネチャをFuture[Unit]に書き換えると、オーバーライドに失敗して継承できない。

Awaitが使えない

Scala.jsでは、実装の都合でscala.concurrent.Awaitを使うことができないので、なんとかしてシグネチャをFutureにするほかない。

www.scala-js.org

いくつかの謎を洗い出すと以下のようになる:

  • なぜTypeScriptはvoidを返すメソッドを継承してPromise<void>にすることが許されているのか?
  • ScalablyTypedはUnitを返すメソッドをなんとかしてFuture[Unit]として扱って良いようにできないだろうか?

謝辞

ちぇっちぇさんに色々テストコードを教えてもらいました。ありがとうございます。

*1:特定のパッケージをimportすることが必要

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