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でもお世話になった。
- Best practices | DefinitelyTyped
- TypeScript Handbook # Writing .d.ts files
- TypeScript Ninja 型定義ファイルのベストプラクティス
上記以外の場合のコツをdtsmakeでの対応も含めていくつか。
namespace
はmodule
の代替ではない
ts1.5から、ES6のモジュールとの混同を避けるため、namespace
キーワードが用意された。だがしかし、これはmodule
キーワードをそのまま置き換えるものではなく、 機械的に置き換えることはできない。
module hoge{/*...*/} //ok
module "hoge"{/*...*/} //ok
namespace hoge{/*...*/} //ok
namespace "hoge"{/*...*/} //NG
module
キーワードは、以下の2つを定義していた。
- ネームスペース (旧称:内部モジュール、文字列でない方)
- アンビエント外部モジュール (文字列の方)
前者はjsでは関数オブジェクトの入れ子で表現される、いわゆる名前空間。後者はnode.jsのrequire()
で指定されるものを定義する。namespace
キーワードはあくまでも名前空間のためのものなので、前者しか代替できない。
古い型定義ファイルでは一つのファイル内に両方記述されていることがよくあるが、それを参考にして単にnamespace
に置き換えるとハマる。
dtsmakeでは原則namespace
のみが出力され、--export
オプションを有効にした時のみモジュール名の定義にmodule
キーワードを使用している。
declare namespace mylib{
/* 中略 */
}
declare module 'mylib'{
export = mylib;
}
exportの新旧方式
ts1.5から旧来のexportスタイルに加えて、ES6スタイルのexportが出来るようになった。既存の定義ファイルは旧来のスタイルのものがほとんどだが、今後はES6スタイルに統一されていくと思われる。
dtsmakeでは--exportStyle
オプションで出力スタイルを指定できる。
declare module 'mylib'{
export = mylib; //legacy ts module export
}
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としての名前を指定できる
declare namespace foo{
/* ... */
}
-
-M
/--exportModuleName
オプションは外部から参照する時のモジュール名を指定できる
declare module "foo/bar"{
//export ...
}
- 組み合わせる事で内部的なネームスペースとモジュール名を分けつつ定義できる
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を定義できる。上記のスコープの問題を解決するなら以下のようになる。
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はクラスだけのものじゃないのでこういった時にも使える。
```javascript: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"*"; //ブラケットアクセスになるけど、一応補完も効く!
**dtsmake**はこういった、JS的にはできるがTSで表現しにくい入れ子クラスや特殊な文字列の関数プロパティを, interfaceとnamespaceの両方をつかうことで型定義するようにしている。
## Global Objectを拡張するコード
JSのライブラリの中にはJSの Global Objectに独自にプロパティを生やして使用するものがある。
TypeScriptのlib.d.tsではGlobal Objectはinterfaceとして定義されているので、再度interfaceで定義拡張できる。
```javascript:
interface Error{
myProp: string;
}
TypeScriptのinterfaceは他のinterfaceを継承できるので拡張部分だけ取り出して定義する方法もある。
interface MyError extends Error{
}
dtsmakeでは、Tern.jsが元々のプロパティも出力してくる関係上、Global Objectと同名のオブジェクトの型定義の方法を--globalObject
オプションで選択できるようにしている。デフォルトでは"wrap"
でネームスペースで括られる。
//--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 役にたてばいいな〜