アカベコマイリ

HEAR NOTHING SEE NOTHING SAY NOTHING

jsdoc-to-assert を試す

JavaScript の型チェックといえば FlowTypeScript だが前者は型の定義ファイルが必要、後者は言語自体を拡張しているため導入コストが少々、高い。もっと手軽に

  • 追加の外部ファイルは不要
  • 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 の処理が開発版でだけ動作するように envdevelopment 側へ定義。こうすると 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 ブラウザの開発者ツールで見てみると

jsdoc-to-assert による assert

こんな感じで 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' ) {
}

という関数があったとする。引数のうち pathdata は必須だが 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 がどのように出力されるか、コンパイルされたコードはどうなっているかを確認できる。

Copyright © 2009 - 2023 akabeko.me All Rights Reserved.