dtslintで型定義ファイルをテストする
UI開発者 加藤最近はツールやライブラリ自体がTypeScriptで作られ、DefinitelyTypedやパッケージ内部に型定義ファイル(*.d.tsファイル)が含まれることも増えてきました。同時に型定義自体もかなり複雑化してきているように思えます。
基本的には型定義ファイル通りでないTypeScriptコードはコンパイルに失敗するはずですが、型定義自体に穴があるとコンパイルに通るべきでないコードが通ってしまうケースもあります。逆に、型定義が更新されたことにより、これまでコンパイルに成功していたコードが失敗することもあり得ます。実際のコードはもちろん、型定義ファイル自体の精度も落とさないよう工夫が必要になってきています。
とはいえ、テストがない状況で関数などの型定義を変更するのはリスクがあります。そんな時にはMicrosoftが公開しているdtslintが使えるかもしれません。dtslintは、型定義ファイルのリントとテストができるツールです。
dtslintのセットアップ
dtslintはnpmで公開されているため、まずはインストールします。
npm i dtslint --save-dev
dtslintによるリントを行うには大きく以下の3つのファイルが必要です。
- tsconfig.json
- 型定義ファイル(index.d.ts)
- tslint.json
今回はこの3つのファイルを「types」というフォルダーに格納する想定で進めます。
tsconfig.jsonの記述
TypeScriptプロジェクトであれば、すでにtsconfig.jsonが存在しているケースが多いですが、ここで作成するtsconfig.jsonはテストコード用のものになります。tsconfig.jsonに記述する内容はプロジェクトによりますがここでは公式の例を記載します。
{
"compilerOptions": {
"module": "commonjs",
"lib": ["es6"],
"noImplicitAny": true,
"noImplicitThis": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noEmit": true,
// If the library is an external module (uses `export`), this allows your test file to import "mylib" instead of "./index".
// If the library is global (cannot be imported via `import` or `require`), leave this out.
"baseUrl": ".",
"paths": { "mylib": ["."] }
}
}
後述するテストコードがエラーにならないように設定をしておくことと、baseUrl
とpaths
を組み合わせて型定義ファイルへのパスを指定しましょう。
型定義ファイルの調整
dtslintでは、複数バージョンのTypeScriptコンパイラでテストできますが、TypeScriptのバージョンによっては使えない型表現なども存在します。
TypeScriptコンパイラのバージョンを固定したい場合は以下の行を型定義ファイルに追加しておきましょう。
// TypeScript Version: 4.3
tslint.jsonの追加
dtslintは内部的にTSLintを使用しているため、そのための設定ファイルを追加します。最低限、以下の内容が書いてあれば問題はありません。
{
"extends": "dtslint/dtslint.json"
}
※ TSLint自体は非推奨となっており、dtslintプロジェクト内でもESLintへ移行するための議論がされています。
dtslintによるリント
ここまで準備ができたら、あとはコマンドでdtslintを実行するだけで型定義ファイルのチェックができます。
npx dtslint ./types
dtslintで定められているルールはGitHubのdocsフォルダーにマークダウンでまとめられています。タブ、スペース、改行などのコーディングスタイルに関するルールや、ユニオン型内でany
を指定していたり、不要なジェネリクスの定義が存在する場合に警告するようなルールがあります。
dtslintによるテスト
dtslintでは、型に関するテストを行うことができます。例えばaddUserData
という関数を以下のようなインターフェースで定義したとします。
export function addUserData(userData: {
name: string,
gender?: string,
}): Promise<void>;
言葉で説明すればaddUserData
関数は、引数に名前と性別を含んだオブジェクトデータをうけとりPromise
オブジェクトを返す関数です。性別はオプショナルとして定義してあります。
この関数をテストするための「test.ts」ファイルを作成し、型定義ファイルと同じ「types」フォルダーに格納します。test.tsの内容は以下の通りです。
import { addUserData } from 'ライブラリの名前';
// $ExpectType Promise<void>
addUserData({
name: 'kato-takeshi',
});
// $ExpectType Promise<void>
addUserData({
name: 'kato-takeshi',
gender: 'male',
});
// $ExpectError
addUserData({
gender: 'male',
});
// $ExpectError
addUserData('kato-takeshi');
このファイルには以下の4つのテストケースが含まれます。
- 引数に名前のみが含まれるケース
- 引数に名前と性別の両方が含まれるケース
- 引数に性別のみが含まれるケース
- 引数に名前がオブジェクトではなく文字列で渡されているケース
最初の2つのケースは型定義通りであるため、コードコメントとして$ExpectType Promise<void>;
と記載し、関数の返り値であるPromise
オブジェクトが返却されることをテストします。
あとの2つのケースは型定義通りではないため、コードコメントとして$ExpectError
と記載し、コンパイルエラーが起きることをテストします。
テスト実行時のコマンドはリント時と同様です。
npx dtslint ./types
例えばaddUserData
関数の引数のうち、オプショナルにしていた「性別」データを必須とするように型定義ファイルを変更すれば「引数に名前のみが含まれるケース」もコンパイルエラーとなるべきです。(先ほどの1つ目のケースがテストに失敗する)
これまでコンパイルできていたコードがエラーになる、ということはこのライブラリを使用しているユーザーが新しいバージョンのライブラリを使用するためにはコードを変更する必要があり、このようなアップデートは「破壊的変更(Breaking Change)」と呼ばれます。
破壊的変更が含まれるようなアップデートをする場合は、ユーザーにアナウンスをすると良いでしょう。
ライブラリなどを作られている方であれば、実際のコードと型定義の整合性を合わせる大変さを実感しているのではないでしょうか。少しでも助けになれば幸いです。