npm パッケージは基本的に、JavaScript ファイルで配布されている。TypeScript で開発しているパッケージであっても、JavaScript にビルドしたものを配布している。
そのため、型定義ファイルによって型付けしないと、インポートした際にモジュール全体がany
になってしまう。
これでは型システムの恩恵を受けることができないし、noImplicitAny
フラグをfalse
にしていない場合はコンパイルエラーになってしまう。
npm パッケージをインポートした際、TypeScript は自動的に型定義ファイルを探索し、最初に見つかったものを使用する。
また、プロジェクト内にある JavaScript ファイルをインポートした際も、型定義ファイルの探索が行われる。
この記事では、TypeScript がどのように型定義ファイルを探索するのか、実際に検証して確認していく。
動作確認に使った TypeScript のバージョンは3.9.6
。
使っているライブラリのバージョンは以下の通り。
- redux@4.0.5
- react-redux@7.2.0
- @types/react-redux@7.1.9
tsconfig.json
は以下。
後述するように、tsconfig.json
の設定は探索アルゴリズムに影響を与える。
{ "compilerOptions": { "target": "ESNext", "outDir": "./dist", "moduleResolution": "Node", "strict": true }, "include": ["./src/**/*"] }
型定義ファイルが同梱されているパッケージで検証
npm パッケージのなかには、型定義ファイルが同梱されているものもある。redux
もそのひとつ。
redux
パッケージさえインストールしておけば、特に何もしなくても自動的に型付けされる。
import {createStore} from 'redux'; const x = createStore; // const x: StoreCreator
このとき TypeScript は、node_modules/redux/index.d.ts
を使って型付けしている。
そのため、このファイルの内容を書き換えると、その変更が反映される。
試しに、createStore
の定義を変更してみる。
export const createStore: number
そうするとcreateStore
の型はnumber
になるため、number
型であるx
に代入できる。
import {createStore} from 'redux'; const x: number = createStore; // ok
package.json で型定義ファイルを指定する
インストールしたパッケージのpackage.json
のtypes
フィールド、もしくはtypings
フィールドで、使用する型定義ファイルを指定することができる。
node_modules/redux/package.json
のtypings
フィールドを見ていると、./index.d.ts
が指定されている。
$ cat node_modules/redux/package.json | grep '"typings"' "typings": "./index.d.ts",
そのため、node_modules/redux/index.d.ts
を型定義ファイルとして使用していたのである。
ではこれを"typings": "./foo.d.ts",
に書き換えるとどうなるか。
実はそれでも、node_modules/redux/index.d.ts
が使用され、コンパイルエラーにはならない。
typings
(もしくはtypes
)フィールドで指定した型定義ファイルが見つからなかった場合、あるいはtypings
やtypes
フィールドが存在しない場合、そのモジュールのルートにあるindex.d.ts
を探す。
今回のケースではtypings
フィールドで指定されている./foo.d.ts
が見つからなかったため、./index.d.ts
を探し、それを使用した。
アンビエントモジュール宣言
declare module 'パッケージ名'
という記法を使って、パッケージの型を自分で定義することもできる。
これを、アンビエントモジュール宣言という。
まず、アンビエントモジュール宣言を書かずに、以下のsrc/index.ts
を書いてみる。
import {createStore, Action} from 'redux'; const x = createStore; // const x: StoreCreator type A = Action; // type A = Action<any>
既述したように、node_modules/redux/index.d.ts
を型定義ファイルとして使用しており、このような結果になる。
次に、src/types.d.ts
を作成し、以下のように書く。
declare module 'redux' { export const createStore: boolean; }
そうすると、このアンビエントモジュール宣言を使って型付けするため、先程のコードはコンパイルエラーになってしまう。
// Module '"redux"' has no exported member 'Action'.ts(2305) import {createStore, Action} from 'redux'; const x: boolean = createStore; // ok type A = Action;
createStore
はboolean
として定義しているため問題ないが、Action
は存在しないため、コンパイルエラーになってしまう。
アンビエントモジュール宣言を以下のように書き換えれば、エラーは消える。
declare module 'redux' { export const createStore: boolean; export type Action = string; }
import {createStore, Action} from 'redux'; const x: boolean = createStore; // ok type A = Action; // type A = string
この挙動から分かるように、パッケージに同梱されている型定義ファイルよりも、アンビエントモジュール宣言が優先される。
型定義ファイルが DefinitelyTyped で管理されているパッケージで検証
次は、型定義ファイルがパッケージに同梱されておらず、DefinitelyTyped で管理されているパッケージで検証してみる。
具体的には、react-redux
で検証する。
インストールしてnode_modules/react-redux
を見てみると分かるが、型定義ファイルは同梱されていない。
そのため、そのままインポートするとコンパイルエラーになる。
// Could not find a declaration file for module 'react-redux'. import {useSelector} from 'react-redux';
react-redux
の型定義ファイルは DefinitelyTyped で管理されているので、@types/react-redux
をインストールすればよい。
そしてインストールするとそれだけで、先程のコードのコンパイルエラーが消える。
つまり、node_modules/@types/react-redux
にある型定義ファイルを、TypeScript が自動的に読み込んでくれている。
node_modules/@types/react-redux/package.json
を見てみるとtypes
フィールドでindex.d.ts
が指定されており、node_modules/@types/react-redux/index.d.ts
を型定義ファイルとして指定していることが分かる。
$ cat node_modules/@types/react-redux/package.json | grep '"types"' "types": "index.d.ts",
そのため、先程と同様、このファイルを書き換えればそれが反映される。
types
(typings
)フィールドでの指定がない、もしくは指定したファイルが存在しない場合は、index.d.ts
を探す。これも、先程と同じである。
次に、以下の内容のnode_modules/react-redux/index.d.ts
を作成してみる。
export function useSelector(): number;
そうすると、useSelector
はnumber
を返す関数になる。
import {useSelector} from 'react-redux'; const x: number = useSelector(); // ok
このことから、パッケージに同梱されている型定義ファイルが優先されることが分かる。
今回の例で言えば、node_modules/@types/react-redux/index.d.ts
よりもnode_modules/react-redux/index.d.ts
が優先される。
JavaScript ファイルと同名の型定義ファイル
この状態で今度は、以下の内容のnode_modules/react-redux/lib/index.d.ts
を作成する。
export function useSelector(): boolean;
そうすると、useSelector
の返り値はboolean
になる。
import {useSelector} from 'react-redux'; const x: boolean = useSelector(); // ok
なぜこのような挙動になるのかと言うと、TypeScript は、インポートしている.js
ファイルと同じディレクトリにある同名の.d.ts
を探索するため。
そしてそれは、パッケージに同梱されている型定義ファイルよりも優先される。
react-redux
の場合、node_modules/react-redux/lib/index.js
をインポートしている。
そのため、node_modules/react-redux/lib/index.d.ts
を用意すると、それを型定義ファイルとして使用する。
ではこの状態でアンビエントモジュール宣言を行うと、どうなるか。
// src/types.d.ts declare module 'react-redux' { export function useSelector(): string; }
// src/index.ts import {useSelector} from 'react-redux'; const x: string = useSelector(); // ok
useSelector
がstring
を返すようになっている。つまり、アンビエントモジュール宣言が最も優先されるということになる。
探索の優先順位
ここまでの内容をまとめると、以下の優先順位になる。
- アンビエントモジュール宣言
- インポートしている
.js
ファイルと同じディレクトリにある、同名の.d.ts
ファイル - 当該パッケージの
package.json
のtypes
(もしくはtypings
)フィールドで指定しているファイル - 当該パッケージのルートにある
index.d.ts
node_modules/@types/当該パッケージ名
のpackage.json
のtypes
(もしくはtypings
)フィールドで指定しているファイルnode_modules/@types/当該パッケージ名/index.d.ts
ただ、 typesVersions を使った制御なども存在するため、上記の内容がアルゴリズムの全てというわけではない。
プロジェクト内の JavaScript ファイルをインポートする際の挙動
TypeScript に移行中のプロジェクトなどでは、同一プロジェクト内にある JavaScript ファイルをインポートすることがある。
// src/utils/foo.js export const add = (x, y) => x + y;
// src/index.ts import {add} from './utils/foo'; const result = add(3, 4);
この場合、型定義ファイルが存在しないため、コンパイルエラーになる。
allowJs
フラグを有効にすればコンパイルエラーは消えるが、全てがany
になってしまう。
import {add} from './utils/foo'; const x = add; // const x: (x: any, y: any) => any const result = add(3, 4); // const result: any
このような場合、先程説明した「インポートしている.js
ファイルと同じディレクトリにある同名の.d.ts
ファイル」を用意することで、型情報を提供することができる。
src/utils/foo.js
をインポートしているので、src/utils/foo.d.ts
を用意すればよい。
export function add(x: number, y: number): number;
これで、型システムの恩恵を受けられるようになった。
import {add} from './utils/foo'; const x = add; // const x: (x: number, y: number) => number const result = add(3, 4); // const result: number
moduleResolution フラグ
ここまでの内容は全て、moduleResolution
フラグの値を"Node"
にした状態で検証してきた。
このフラグは"Node"
の他に"Classic"
が設定可能だが、そうすると、npm パッケージをインポートする際の挙動が大きく変わり、ここまで紹介してきた場所に型定義ファイルが存在してもコンパイルエラーになってしまう。
// Cannot find module 'redux' or its corresponding type declarations. import {createStore, Action} from 'redux';
"Classic"
は互換性のために存在するだけで、明示的にこの値を設定することはないと思われる。
だが、moduleResolution
の値を指定しなかった場合、他のフラグの設定によっては"Classic"
になってしまう。
例えばtsconfig.json
を以下の内容にすると、moduleResolution
は"Classic"
になってしまい、先程のようなコンパイルエラーが発生してしまう。
{ "compilerOptions": { "target": "ESNext", "outDir": "./dist", "strict": true, }, "include": ["./src/**/*"] }
プロジェクトのmoduleResolution
がどのような設定になっているか、注意する必要がある。