30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

TypeScript の型定義ファイルの探索アルゴリズム

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.jsontypesフィールド、もしくはtypingsフィールドで、使用する型定義ファイルを指定することができる。

node_modules/redux/package.jsontypingsフィールドを見ていると、./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)フィールドで指定した型定義ファイルが見つからなかった場合、あるいはtypingstypesフィールドが存在しない場合、そのモジュールのルートにある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;

createStorebooleanとして定義しているため問題ないが、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",

そのため、先程と同様、このファイルを書き換えればそれが反映される。

typestypings)フィールドでの指定がない、もしくは指定したファイルが存在しない場合は、index.d.tsを探す。これも、先程と同じである。

次に、以下の内容のnode_modules/react-redux/index.d.tsを作成してみる。

export function useSelector(): number;

そうすると、useSelectornumberを返す関数になる。

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

useSelectorstringを返すようになっている。つまり、アンビエントモジュール宣言が最も優先されるということになる。

探索の優先順位

ここまでの内容をまとめると、以下の優先順位になる。

  1. アンビエントモジュール宣言
  2. インポートしている.jsファイルと同じディレクトリにある、同名の.d.tsファイル
  3. 当該パッケージのpackage.jsontypes(もしくはtypings)フィールドで指定しているファイル
  4. 当該パッケージのルートにあるindex.d.ts
  5. node_modules/@types/当該パッケージ名package.jsontypes(もしくはtypings)フィールドで指定しているファイル
  6. 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がどのような設定になっているか、注意する必要がある。