TypeScript
dtsmake
Tern

TypeScript型定義ファイルのコツと生成ツール dtsmake

JavaScriptからTypeScript用型定義ファイル( d.tsファイル )を生成する dtsmake というツールを作った。その過程で型定義ファイルのコツが色々と見えたので紹介込みでまとめてみたい。


型定義ファイルで消耗してませんか?

TypeScriptでjsのライブラリなどを使う時に必ず問題になるのが、型定義ファイルの存在。DefinitelyTyped にあれば tsd で取ってくればいいが、問題は無い場合。そもそも非公開ライブラリの場合はあるはずもなく、自分で型定義ファイルを書くことになる。

私のようなノンプログラマの多くがそうであるように、ただライブラリを使いたいだけ、のような場面ではこれはかなりのコストがかかり、TypeScriptで消耗する原因のかなりの割合を占めるのではないかと推測している。


dtsmake について

dtsmakeはそういった「 とりあえず使いたいんだ!」という要望の手助けとなればいいな、というツール。Node.jsのコマンドとしてnpmで公開してあり、JavaScriptファイルをソースとして渡すと型定義ファイルを出力する。何も考えずに dtsmake を通せばすぐにtsコードで使えることが目標。

現時点ではαクオリティで、色々なjsコードでのテストがまだまだ不十分。とりあえず動くようになったので公開。


特長: 型推論

dtsmake は特長として型推論 ( Type Inferrence )に基づく型情報を出力する。いくらすぐ使えるといってもany型だけではあまり意味がないのでできる限り使える型定義ファイルを出力するようにしている。

さらに、デフォルトの状態ではany型ばかりになりがちだが、サンプルコードをオプションとして複数指定すると精度が上がる。

この dtsmake の型推論は JSの分析エンジンである Tern.js を利用している。TernはemacsなどのJSコードの補完プラグインのエンジンとしてよく使われている。


インストール&型定義ファイル生成

npm i dtsmake -g

dtsmake -s ./path/to/targetfile.js

上記のコマンドだけで最低限使える。ただし、型推論の精度と、後述する型定義ファイルの問題で、実際には色々オプションを組み合わせる必要がある。


オプション

githubのREADMEにオプション一覧がある。

オプションは主に2種類に別れる。Tern.JSのオプション由来、JSコードの解析用オプションと、dtsmakeが出力する型定義ファイルを色々な状況に合わせて変更するオプション群、の2種類。


Tern.js由来のJSコード解析用オプション

-x, --extrafiles <paths>

サンプルコードをこのオプションで指定すると、型推論の精度が上がる。既にライブラリの場合はサンプルや実際に使用しているコードがあると思うので、出来る限り指定するのがおすすめ。-x "path/to/sample1.js,path/to/sample2.js"の用に,区切りで複数指定できる。


型定義ファイルの出力オプション

TypeScriptの型定義ファイルは、あるものに型定義するだけでも複数の方法があったりしてなかなか難しい。以下のコツの章で具体的に触れる。


型定義ファイルのコツ

基本的には以下のドキュメントを参考にするといい。dtsmakeでもお世話になった。

上記以外の場合のコツをdtsmakeでの対応も含めていくつか。


namespacemoduleの代替ではない

ts1.5から、ES6のモジュールとの混同を避けるため、namespaceキーワードが用意された。だがしかし、これはmoduleキーワードをそのまま置き換えるものではなく、 機械的に置き換えることはできない


moduleとnamespaceの違い

module hoge{/*...*/} //ok

module "hoge"{/*...*/} //ok
namespace hoge{/*...*/} //ok
namespace "hoge"{/*...*/} //NG

moduleキーワードは、以下の2つを定義していた。

1. ネームスペース (旧称:内部モジュール、文字列でない方)

2. アンビエント外部モジュール (文字列の方)

前者はjsでは関数オブジェクトの入れ子で表現される、いわゆる名前空間。後者はnode.jsのrequire()で指定されるものを定義する。namespaceキーワードはあくまでも名前空間のためのものなので、前者しか代替できない。

古い型定義ファイルでは一つのファイル内に両方記述されていることがよくあるが、それを参考にして単にnamespaceに置き換えるとハマる。

dtsmakeでは原則namespaceのみが出力され、--exportオプションを有効にした時のみモジュール名の定義にmoduleキーワードを使用している。


--exportオプションを有効にすると出力

declare namespace mylib{

/* 中略 */
}
declare module 'mylib'{
export = mylib;
}


exportの新旧方式

ts1.5から旧来のexportスタイルに加えて、ES6スタイルのexportが出来るようになった。既存の定義ファイルは旧来のスタイルのものがほとんどだが、今後はES6スタイルに統一されていくと思われる。

dtsmakeでは--exportStyleオプションで出力スタイルを指定できる。


"legacy"

declare module 'mylib'{

export = mylib; //legacy ts module export
}


"es6"

declare module 'mylib'{

export default mylib; //es6 style module export
}


ネームスペース名と外部に見せるモジュール名を分ける

主にNodejsのモジュールなどで、以下のようなコードで呼び出す前提のモジュールがあるとき。

var bar = require("foo/bar");

前述のようにnamespace "foo/bar"{}とは定義できない。namespace foo.bar{}は定義できるが、"foo/bar"という名前で呼び出せない。どのように型定義ファイルで定義すべきか悩み所である。

dtsmakeでは-nオプション--exportModuleNameオプションそれぞれに別名を指定する事で分ける事ができる。



  • -nオプションはnamespaceとしての名前を指定できる


-n foo


declare namespace foo{
/* ... */
}




  • -M/--exportModuleNameオプションは外部から参照する時のモジュール名を指定できる


-M "foo/bar"


declare module "foo/bar"{
//export ...
}



  • 組み合わせる事で内部的なネームスペースとモジュール名を分けつつ定義できる


-n foo -M "foo/bar"

declare namespace foo{

/* ... */
}
declare module "foo/bar"{
export = foo;
}


interfaceとnamespaceは同じ名前で定義できる

TypeScriptではClassやInterfaceの子にClass/Interfaceを定義できない(ver.1.6以降でLocal typeとして可能になる予定)。ところが、JSのコードではそういう制限はないのでクラス(っぽいもの)の子にクラス(っぽいもの)を定義できる。


入れ子クラス

//こういうJSがあったとして

var Foo = (function () {
function Foo() {
}
var Bar = (function () {
function Bar() {
}
return Bar;
})();
return Foo;
})();

//こう型定義できない…
interface Foo{
interface Bar{ //error!
/* ... */
}
}


解決策としてはFooのプロパティとしてBar型のBarというプロパティを定義する事でもいける。が、Barが既にそのスコープで定義されていた場合バッティングしてしまう。

TypeScriptはこの辺柔軟性があり、Interface名と同じ名前でnamespaceを定義できる。上記のスコープの問題を解決するなら以下のようになる。


入れ子の定義をnamespaceと一緒に

interface Foo{

}
declare namespace Foo{
interface Bar{
//ok
}
}
//型指定時
var f:Foo.Bar;

namespace名と同じ名前のinterfaceを定義することでも解決することがある。

例えば、foo.bar.*(a, b)という関数オブジェクトをどう型定義すればいいだろうか?


*という名前で関数定義できない!

declare namespace foo.bar{

var prop1:number;
var prop2:string;

//こうしたいけど...
function *(a:number, b:number):void; //error!

//こういうのもエラー
function "*"(a:number, b:number):void;
function ["*"](a:number, b:number):void;
var *:(a:number, b:number)=>void;
var "*":(a:number, b:number)=>void;
var ["*"]:(a:number, b:number)=>void;
}


*というのはプロパティ名として使えるがfunctionキーワードで関数定義する際には使えない文字だ。JS的にはfoo.bar["*"] = function(){/*...*/};といくらでも定義できる。だがこれを型定義するのはどうしたらいいか。

解決策としては、TypeScriptのinterfaceはクラスだけのものじゃないのでこういった時にも使える。


`foo.bar.*`という関数の型定義はこうだ!

//namespaceでは関数定義しない

declare namespace foo.bar{
var prop1:number;
var prop2:string;
}

//関数定義をinterfaceの中に追い出す
declare namespace foo{
interface bar{
//interface内ではfunctionキーワード使わないのでエラーにならない!
"*": (a:number, b:number)=>void;
}
}

//使用時
var hoge:foo.bar;
hoge["*"](0,1); //ブラケットアクセスになるけど、一応補完も効く!


dtsmakeはこういった、JS的にはできるがTSで表現しにくい入れ子クラスや特殊な文字列の関数プロパティを, interfaceとnamespaceの両方をつかうことで型定義するようにしている。


Global Objectを拡張するコード

JSのライブラリの中にはJSの Global Objectに独自にプロパティを生やして使用するものがある。

TypeScriptのlib.d.tsではGlobal Objectはinterfaceとして定義されているので、再度interfaceで定義拡張できる。

interface Error{

myProp: string;
}

TypeScriptのinterfaceは他のinterfaceを継承できるので拡張部分だけ取り出して定義する方法もある。

interface MyError extends Error{

}

dtsmakeでは、Tern.jsが元々のプロパティも出力してくる関係上、Global Objectと同名のオブジェクトの型定義の方法を--globalObjectオプションで選択できるようにしている。デフォルトでは"wrap"でネームスペースで括られる。


--globalObject

//--globalObject "remove"

// ※出力なし

//--globalObject "wrap"
declare namespace mylib{
interface Error{
//...
}
}

//--globalObject "rename"
interface Mylib$Error{
//...
}


ちなみにts1.6ではGlobal Object(built-in class)を継承したclassが定義できるようになるらしいので、また選択肢が増えることになるかもしれない。


まとめ


  • TypeScriptの型定義ファイルは奥が深い

  • ES6対応で色々かわりつつある途中

  • dtsmake 役にたてばいいな〜