PureScript から TypeScript 用型定義 (.d.ts) を生成するツールを作った

前置き

TypeScript で作っていたプロジェクトに、後付けで PureScript を追加しようとしたらかなり辛かった。

(わざわざ言語を混在させたい理由としては、型クラスや演算子オーバーロードを使いたい&既存のコードを全部書き直す暇はない、が挙げられる)

辛い理由としてはそもそもモジュールとバンドラーの周辺がまだ成熟していないというのもあるだろうが、 TypeScript 固有の理由として、 TypeScript コードから PureScript モジュールを読み込むための型定義が足りないという問題がある。

Stack Overflow を見ると、同じことで悩んでいる人がいた:

しかし、どの解決策もイマイチである。

コンパイル済みの PureScript を使うだけなら --allowJs オプションという手もあるだろうが、せっかく型がある言語で書いたのだから、適切な型チェックがされて欲しい。PureScript のコンパイル時に TypeScript 用の型定義ファイル .d.ts を出力させるようにはできないのか?

PureScript の GitHub Issues にも「.d.ts を生成させたい!」というトピックがあるが、特に動きがあるようには見えない。

コンパイラーにそういう機能がないならば、自分で作ってしまおう!ということで、作った。

https://github.com/minoki/purescript-tsd-gen

作ったもの (purs-tsd-gen コマンド) は PureScript のコンパイラー(purs コマンド)とは独立に動く Haskell プログラムである。PureScript のコンパイル時のコマンドはいじる必要はないが、 PureScript のコンパイル後、 TypeScript のコンパイル前に動かす必要がある。

現時点では npm には登録していないので、自分で git clone してビルドしてもらいたい。

使い方

PureScript をコンパイルしたら、典型的には output/ 以下に各モジュールが出力される:

$ tree output/
output/
├── Control.Alt
│   ├── externs.json
│   └── index.js
├── Control.Alternative
│   ├── externs.json
│   └── index.js
...
└── Hoge
    ├── externs.json
    └── index.js

このとき、  purs-tsd-gen-d 引数でディレクトリ名を、そのあとの通常の引数で TypeScript から使いたいモジュール名を並べる。たとえば、 Hoge という名前のモジュールを TypeScript から使いたいのであれば、

$ purs-tsd-gen -d output/ Hoge

を実行すれば良い。すると、それぞれのモジュールのディレクトリに、

$ tree output/
output/
├── Control.Alt
│   ├── externs.json
│   ├── index.d.ts
│   └── index.js
├── Control.Alternative
│   ├── externs.json
│   ├── index.d.ts
│   └── index.js
...
└── Hoge
    ├── externs.json
    ├── index.d.ts
    └── index.js

という風に index.d.ts が生成される。(externs.json に PureScript の型情報が含まれているため、もとの .purs ファイルを教えてやる必要はない。)

あとは、 TypeScript コードから

import * as Hoge from "./output/Hoge";

という風に PureScript 製モジュールを import すれば良い。

方針

PureScript で実装した関数のうち、「JavaScript の世界から使われることを意図したもの」にうまく型をつけることを目指す。

いわゆる FFI では、橋渡しされる言語よりも表現力が劣ることが普通である。

例えば、 C++ と他の言語の橋渡しをする場合は C++ のクラスやメンバー関数の概念は持ち出せず、一旦 C の関数と構造体を経由する必要がある。別の例として、 C言語の関数は構造体を値として受け渡すことができるが、 Haskell と C言語の FFI ではそういうことはできない。

それと同じように、 PureScript の全てにうまく TypeScript の型を当てはめる必要はないと考える。

(それとは別に、 TypeScript の型システムで高階型などを再現する試みは興味深い。参考:https://github.com/gcanti/fp-ts

また、あまりドキュメント化されていない実装の詳細(型クラスの辞書とか)にあまり型をつけても仕方がない感じはする。

PureScript の宣言と TypeScript の宣言の対応

PureScript のモジュールからエクスポートされるものを、 TypeScript 側の宣言としてどのように扱うべきか考えよう。

関数と値:定数として普通に export する。

型:カインドが (Type → )* Type ならば、普通の型 T または型引数を持つ型 T<a0, ..., an> に変換する。そうでない場合は、現状は対応しない。詳しくは後述する。

データ構築子:コンストラクター関数を export する。コンストラクター関数は static フィールドとして value または create を持つ。

型クラス:辞書渡しとして脱糖後のものを扱う。

インスタンス:辞書として脱糖後のものを扱う。

(カインドや演算子定義などは、 PureScript 特有の概念なので、対応しない)

PureScript の型と TypeScript の型の対応

型の区別

まず最初に、 PureScript の型システムには部分型はないが、 TypeScript は構造的部分型を採用しているという違いがある。

次の2つの型

data Foo = Foo Number
data Bar = Bar Number

はどちらも、「1個のフィールドを持つオブジェクト」にコンパイルされ、それを TypeScript で書くと次のようになる:

interface Foo {
    value0: number;
}
var Foo: {
    create(value0: number): Foo;
    new(value0: number): Foo;
};
interface Bar {
    value0: number;
}
var Bar: {
    create(value0: number): Bar;
    new(value0: number): Bar;
};

しかし、この Foo 型と Bar 型は TypeScript のシステムでは区別されない。まあ TypeScript 的にはそれでもいいのかもしれないが、 PureScript 側から Foo だと思って受け取ったものを PureScript 側に Bar として渡してしまうというのはおそらくバグであろう。

というわけで、ダミーのフィールドをつけて Foo 型と Bar 型を区別できるようにする:

interface Foo {
    $$pursType?: "Foo";
    value0: number;
}
interface Bar {
    $$pursType?: "Bar":
    value0: number;
}

こうすることで、 Foo 型と Bar 型は互いに互換性のない型となる。なお、 TypeScript 側で { value0: 123 } として作った型は  Foo 型と Bar 型のいずれにも代入できる。

直和

代数的データ型が複数のデータ構築子を持つ場合にどうするか。

PureScript から吐かれる JavaScript では、それぞれのデータ構築子は JavaScript 的なコンストラクター(new で呼び出される関数)に対応する。パターンマッチは instanceof によるチェックとなる。

一方、 TypeScript で通常使われる直和型はタグ付き union であり、正直言って相性は悪い。

しかし、 instanceof による type guard も TypeScript で一応サポートされているので、こんなことができる:

import { Maybe, Just, Nothing } from "./output/Data.Maybe";

function printMaybe(m: Maybe<string>) {
    if (m instanceof Nothing) {
        console.log("Nothing");
    } else {
        // ここで m は Just<string> に相当する型になる
        console.log("Just " + m.value0);
    }
}

printMaybe(Just.create("hoge"));

この辺の挙動は TypeScript の重箱の隅である(Maybe型の定義を、オブジェクトリテラル型を使って type Maybe<a> = {tag:"Nothing"} | {tag:"Just";value0:a} とするとこれは上手くいかないが、 Nothing と Just に相当する型を interface を使って定義すると上手くいく)。

ちなみに、複数のデータコンストラクターを含む型は PureScript 的には instanceof で区別するという都合があるため、「ダミーのフィールド」は必須のフィールドとし、 TypeScript 側で {value0: 123} という風に作ったオブジェクトを間違えて放り込むことのないようにしている。

その他

よくわからない型は TypeScript の世界では any になる。まあ、 TypeScript に対応しないようなよくわからない型を TypeScript から使おうとは思わないだろう。

今後の課題

  • Flow のサポート

Creating Library Definitions | Flow によると、 .js.flow というファイルを作れば .d.tsと同じようなノリで型定義を与えることができるようである。

筆者は Flow を使ったことはないのだが、このツールが扱う範囲では TypeScript の型システムと Flow の型システムにはそこまで大きな違いはないはずなので、やろうと思えばできるはずである。

あとは、どれほどの需要があるかという問題である(誰も使わない機能を実装しても仕方がないので)。

  • 高階型のサポート

Functor みたいな型クラスのサポートをできるかどうか。高度な型システムを TypeScript から使いたいという状況は考えづらいので、実装できたとしても実験的な意味合いが強いだろう。

  • foreign import data / よく使われる PureScript のライブラリーに対していい感じの定義を与える

現状、 foreign import data で定義されたデータ型は原則として any 型に翻訳するようにしている。しかし、これでは Data.Function.Uncurried.Fn0〜Fn10 が関数型じゃなくて any 型になってしまったりして都合が悪い。

そこで、 PureScript の一部のライブラリーで提供される型に関しては、 purs-tsd-gen 側で「いい感じの」翻訳を与えるようにしている。

  • Data.Function.Uncurried.Fn0 〜 Fn10 → 関数型
  • Control.Monad.Eff → 引数を取らない関数型
  • Data.StrMap → {[_:string]: a}

需要があるようなら、このような特別扱いの範囲を拡充したい。例えば、 purescript-variant は union 型に翻訳したほうがいいかもしれない。

  • readonly にすべき?

現状、出力する型定義は readonly を考慮していない。出力する型に片っ端から readonly を付与するオプションがあってもいいかもしれない(やるとしても選択肢を増やすだけで、強制 readonly にはしない)。

  • 宣伝

作ったはいいが、コミュニティーに認知させないとただの自己満足で終わる。

とりあえず、PureScript の GitHub Issue の該当トピック Generate TypeScript Definition Files · Issue #29 · purescript/purescript には投稿した。ググって見つけた Stack Overflow にも投稿したほうがいいかもしれない。

PureScript 歴が浅いため、 PureScript のコミュニティーがどこにあるのかよくわかっていない。Reddit にでもタレ込めばいいのだろうか。

いずれにせよ、英語で purs-tsd-gen を紹介する記事(この記事の英語版)があったほうが良さそうだ。

  • 配布

git clone して自分で stack を使ってビルドしろ、というのではあまりに敷居が高い。

JavaScript / altJS のエコシステムに属することを考えると npm でインストールできるべきなのだろうが、このツールは JavaScript ではなく(PureScript コンパイラーの機能に依存する都合により)Haskell で書かれているため、バイナリ配布する必要がある。リリースするごとに Linux, macOS, Windows のそれぞれに対してバイナリを生成するのはだるい。

(ちなみに、 PureScript のコンパイラー自体は npm でバイナリをインストールできるようになっている。)

GHCJS や Haste 等で Haskell から JavaScript に変換してそれを配布するという手もあるかもしれないが、どのぐらい実現可能性があるのかよくわからない。

  • ビルドツールとの協働

WebPack だか Rollup だか知らないが、そういうビルドツール・バンドラーからシームレスに呼び出せるようにしたほうが良いだろう。

  • PureScript のバージョン依存性

このツールは PureScript コンパイラーの機能に依存する。PureScript のバージョンが上がると追従する必要があるだろう。面倒くさい。


コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です