jsdoc-to-assert を試す
JavaScript の型チェックといえば Flow や TypeScript だが前者は型の定義ファイルが必要、後者は言語自体を拡張しているため導入コストが少々、高い。もっと手軽に
- 追加の外部ファイルは不要
- JavaScript の組み込み型ぐらいをチェックできればよい
ぐらいのものがあれば、と探してみたら azu/jsdoc-to-assert がそんな感じなので試してみる。
- 2016/8/24 「省略可能な引数」と「null 許容型」の項を追記
jsdoc-to-assert
jsdoc-to-assert は JSDoc 形式で書かれた関数のコメントを元に型チェックする。開発の経緯や詳細については JSDocをランタイムassertに変換するBabelプラグインを書いた | Web Scratch を参照のこと。処理は単純で、
- JSDoc の
@param
から引数の型と名前を取得 - 関数の冒頭に引数の型チェックを
console.assert
形式で埋め込む
というもの。例えば
/**
* Output log.
*
* @param {String} message Message text.
*/
function func( message ) {
console.log( message );
}
というコードを jsdoc-to-assert に渡すと
/**
* Output log.
*
* @param {String} message Message text.
*/
function func(message) {
console.assert(typeof message === "string", 'Invalid JSDoc: typeof message === "string"');
console.log(message);
}
に変換される。型チェックが偽ならば、その情報を assert
として出力。Firefox や Chrome の開発者ツールであれば関数の呼び出しと assert
箇所をコンソールから確認できるので不正な値を指定した処理を修正するためのヒントになる。
環境構築と注意点
jsdoc-to-assert は本体と Babel plugin/preset 版が提供されている。plugin が Babel 用の機能拡張で、preset はそれをセットでパッケージ化したものになる。
いまのところ preset には babel-plugin-jsdoc-to-assert
しか含まれていないので、どちらを選んでも機能差はないはず。とはいえ preset にしておけば依存が増えた時にも対応されるだろうから今回は babel-preset-jsdoc-to-assert
を採用。
以上を踏まえて package.json
を定義。
{
"babel": {
"passPerPreset": true,
"presets": [
"es2015"
],
"env": {
"development": {
"presets": [
"power-assert",
"jsdoc-to-assert"
]
}
}
},
"browserify": {
"transform": [
"babelify"
]
},
"scripts": {
"test": "mocha --compilers js:babel-register test/**/*.test.js",
"build:": "browserify ./src/js/App.js -d | exorcist ./src/assets/bundle.js.map > ./src/assets/bundle.js",
"watch": "watchify ./src/js/App.js -v -o \"exorcist ./src/assets/bundle.js.map > ./src/assets/bundle.js\" -d",
"release": "cross-env NODE_ENV=production browserify ./src/js/App.js | uglifyjs -c warnings=false -m > ./dist/bundle.js"
},
"devDependencies": {
"babel-preset-es2015": "^6.13.2",
"babel-preset-jsdoc-to-assert": "^3.0.2",
"babel-preset-power-assert": "^1.0.0",
"babel-register": "^6.11.6",
"babelify": "^7.3.0",
"browserify": "^13.1.0",
"cross-env": "^2.0.0",
"exorcist": "^0.4.0",
"mocha": "^3.0.2",
"npm-run-all": "^3.0.0",
"power-assert": "^1.4.1",
"uglify-js": "^2.7.3",
"watchify": "^3.7.0"
}
}
Babel の設定は .babelrc
に書いてもよい。というかそのほうが一般的だと思う。私はなるべく package.json
にプロジェクト設定を集約したい派なのでこちらへ定義した。Browserify 設定も同様。Babel 関連の npm をまとめる。
npm | 用途 |
---|---|
babel-preset-es2015 | ES2015 変換用プリセット。 |
babel-preset-jsdoc-to-assert | jsdoc-to-assert を利用するためのプリセット。 |
babel-preset-power-assert | 単体テストで assert を power-assert に置換するためのプリセット。 |
babel-register | Babel 変換時、import/require を補足するためのモジュール。assert の power-assert 置換などに必要。 |
babelify | Browserify 用 Babel プラグイン。 |
今回は開発用のコードと単体テストの両方で jsdoc-to-assert を試したいので power-assert 関連も利用する。JavaScript 全体の bundle 化用に Browserify を使いたいので、Babel 本体は babelify を採用。Browserify が不要なら babel-cli を選んでもよい。
Babel の設定について。
{
"babel": {
"passPerPreset": true,
"presets": [
"es2015"
],
"env": {
"development": {
"presets": [
"power-assert",
"jsdoc-to-assert"
]
}
}
}
}
冒頭の passPerPreset
が重要。これを true
にするとプラグイン単位で変換をハンドリング可能。プラグインの実行順で変換結果に問題が起きたときに指定するオプションである。
jsdoc-to-assert を試したとき Object の property として定義された関数などが無視されたので報告したところ、修正報告にこの設定を有効にしてほしいと回答されていたので、そのようにしている。
もうひとつ jsdoc-to-assert の処理が開発版でだけ動作するように env
の development
側へ定義。こうすると NODE_ENV=production
のときは除外される。npm-scripts の
{
"scripts": {
"release": "cross-env NODE_ENV=production browserify ./src/js/App.js | uglifyjs -c warnings=false -m > ./dist/bundle.js"
},
}
がそれ。npm-scripts から NODE_ENV
は操作できないので cross-env を利用した。この方法は React をリリース用ビルドして余計な開発用コードを除去するなどでも必要なので個人的に定石となっている。
実行してみる
package.json
の定義と型チェック用の JavaScript を用意したら実行してみよう。
$ npm run build
出力された bundle.js
をテキスト エディターで開いてみると console.assert
が挿入されることを確認できるはず。わざと間違った型を指定子したスクリプトを実装、それを読み込んだ HTML を Web ブラウザの開発者ツールで見てみると
こんな感じで assert
が出力されているはず。なお jsdoc-to-assert v2.4.2 時点では以下の関数が assert
対象となる。
- キーワード
function
で定義される通常の関数 Object
のプロパティーとして定義された関数- ES2015 class の
constructor
- ES2015 class のメソッド
- ES2015 class の
static
メソッド - ES2015 class のプロパティー setter
ES2015 の Arrow Function は対応していないようだ。これはコールバックなどで局所的に使用されることが多い。それに対して JSDoc を書く機会は少ないだろうし、さほど困らない。型チェックの対象となるのは JavaScript の組み込み型になる。ES2015 class を定義し、それを型として JSDoc の @param
へ指定しても無視される。
もうひとつ注意。jsdoc-to-assert は @param
が正しく書かれることを前提としている。変数名が間違っていると常に assert
がヒットすることになるため注意すること。引数の名前を修正するとき JSDoc に反映し忘れるとこの問題に遭遇する。というか、した。
単体テストの assert
単体テストの対象となるコードに jsdoc-to-assert が適用されるとどうなるのか。私の利用している mocha + power-assert の組み合わせで試してみた。
describe( 'Valid', () => {
describe( 'Sample', () => {
it( 'constructor', () => {
const sample = new Sample( 'message' );
assert( sample.message === 'message' );
} );
} );
} );
のような正常系と
describe( 'Invalid', () => {
/** @test {Sample} */
describe( 'Sample', () => {
/** @test {Sample#constructor} */
it( 'constructor', () => {
const sample = new Sample( 7 );
assert( sample.message === 'message' );
} );
} );
} );
という異常系を実装してテストを走らせてみる。
$ npm test
> using-jsdoc-to-assert@1.0.0 test .../jsdoc-to-assert
> mocha --compilers js:babel-register test/**/*.test.js
Valid
Sample
✓ constructor
Invalid
Sample
1) constructor
1 passing (2ms)
1 failing
1) Invalid Sample constructor:
AssertionError: Invalid JSDoc: typeof message === "string"
+ expected - actual
-false
+true
at Console.assert (console.js:95:23)
at new Sample (Sample.js:52:26)
at Context.<anonymous> (Sample.test.js:96:22)
jsdoc-to-assert の埋め込むものは console.assert
なので mocha 上の扱いが気になっていたが、普通に failing となった。power-assert のように値の詳細は表示されないものの位置はわかるため、型を修正するヒントとしては十分である。
余談だが単体テストで実行される関数がどのように変換されたかを知りたい場合は
describe( 'Invalid', () => {
it( 'logStrStatic', () => {
Sample.logStrStatic( 'message' );
console.log( String( Sample.logStrStatic ) );
} );
} );
のように関数自体を String で文字列化して console.log
するとよい。出力結果は
function logStrStatic(message) {
console.assert(typeof message === "string", 'Invalid JSDoc: typeof message === "string"');
console.log(message);
}
となる。この例だと jsdoc-to-assert により埋め込まれたコードを確認できる。
省略可能な引数
関数を設計する際、オプション扱いの設定などを省略可能にすることがある。例えば
/**
* 指定されたファイルにデータを書き込みます。
*
* @param {String} path 書き込み対象となるファイルのパス情報。
* @param {Buffer} data 書き込むデータ。
* @param {String} mode 書き込みモード。省略時はファイルを新規作成します。
*
* @return {Boolean} 成功時は true。
*/
function writeFile( path, data, mode = 'w' ) {
}
という関数があったとする。引数のうち path
と data
は必須だが mode
は省略可能、その場合はデフォルトの動作をする仕様。これをそのまま jsdoc-to-assert すると mode
の型チェックが挿入されるため省略時に assert
されてしまう。
Use JSDoc: @param の Optional parameters and default values を読むと省略可能な引数は {String=}
のように記述するようだ。しかし jsdoc-to-assert では型として認識されず assert
が生成されない。よって型の列挙を代替案とする。
/**
* 指定されたファイルにデータを書き込みます。
*
* @param {String} path 書き込み対象となるファイルのパス情報。
* @param {Buffer} data 書き込むデータ。
* @param {String|undefined} mode 書き込みモード。省略時はファイルを新規作成します。
*
* @return {Boolean} 成功時は true。
*/
function writeFile( path, data, mode = 'w' ) {
}
mode
の型を String
から String|undefined
に変更することで mode
が undefined にもなり得ることを示す。これを jsdoc-to-assert すると
function writeFile( path, data, mode ) {
// ...略
console.assert(typeof mode === "string" || typeof undefined === "undefined" || mode instanceof undefined, 'Invalid JSDoc: typeof mode === "string" || (\ntypeof undefined === "undefined" || mode instanceof undefined\n)');
}
というコードが生成された。@param
の型に undefined
が登場すると assert
内で常に true
となる箇所が埋め込まれる。そのため評価がここに達すれば常に assert
を通るわけだ。結果として省略可能な引数の指定に使える。
後続に到達できない instanceof
が登場することから、もしかするとバグなのかもしれない。なお undefined
のかわりに null
を指定したときも同様の assert
が生成される。
null
の型は object
なので、これを列挙される型として判定するなら value !== null
のように値そのものが null
であることを調べなくてはいけない。
null 許容型
省略可能な引数の扱いを調べていて、そういえば JSDoc 的に null
許容型はどういう扱いなんだろう?とググッてみたら Use JSDoc: @type に説明されていた。
Number
型があるとして、それを null
許容型にするなら {?Number}
、許容しないなら {!Number}
とする。試しにこれを jsdoc-to-assert に渡してみたら
/**
* Output log.
*
* @param {String|?String} message Message text.
* @param {String|!String} message2 Message text.
*/
function func(message, message2) {
console.assert(typeof message === "string" || message == null || typeof message === "string", 'Invalid JSDoc: typeof message === "string" || (message == null || typeof message === "string")');
console.assert(typeof message2 === "string" || message2 != null && typeof message2 === "string", 'Invalid JSDoc: typeof message2 === "string" || (message2 != null && typeof message2 === "string")');
console.log(message);
}
というコードを生成。値をちゃんと null
判定しており許容と非許容も区別されていた。判定の演算子を ===
や !==
にしないのは null
判定においてを厳密に型チェックする意味がないからだろうか。なお null 許容だけだと型チェックが抜けるので、本来の型も列挙しておいたほうがよい。
あと ESDoc もこの記法に対応していた。null
許容と非許容を適切に判定し、元の型へのリンクを生成しつつ Attribute 欄に nullable: false/true
を記述してくれる。この情報を知れたのは収穫だった。
まとめ
JavaScritp 組み込み型に限定されるが JSDoc さえキッチリ書いておけば、それだけで型チェックできるというのは便利。独自の型を使用するような場所ではダック タイピングすればよい。その場合は assert
ではなくユーザー向けコードでも明示的にインターフェースのチェックとエラー処理を実行したくなるだろう。
そういう意味でも今の私なら jsdoc-to-assert ぐらいで実用十分。
今回の記事で作成したサンプル プロジェクトを以下に公開した。実際に動かして assert
がどのように出力されるか、コンパイルされたコードはどうなっているかを確認できる。