【TypeScript】型定義ファイルをちゃんと理解したい

TypeScriptを書いていると、たまに型定義ファイル`xxx.d.ts`を見かけます。

単に、型定義を書き並べたファイルという理解だったのですが、調べてみると意外に奥が深かったので、役に立ちそうな内容を記事にまとめてみました。

記事の構成は、

- 型定義ファイルの概要
- 型定義の基本事項
    - Declaration Merge
    - モジュール
- 練習問題
    - Jestの型定義を拡張する

となっています。


## 型定義ファイルとは

ご存知のように、TypeScriptはJavaScriptに静的型付けなどを追加した言語です。

JavaScriptとの互換性は維持しているので、JavaScriptはTypeScriptとして実行可能です。そのため、JavaScriptで記述された様々なライブラリを、そのままTypeScriptのライブラリとして使うことができます (すごい！！)

しかし、JavaScriptで記述されたコードには、型情報がありません。それらのコード中の変数や関数は、TypeScriptでは全てany型として扱われるので、ライブラリの使用部分では静的型付けの保護を受けることができません。

これでは不便なので、TypeScriptはJavaScriptに対して、後付けで型定義を付与できる仕組みを用意しています。それが、型定義ファイルと呼ばれるものです。

### 初めての型定義ファイル

試しに、簡単な型定義ファイルを書いてみましょう。

ここでは、下記のJavaScriptファイルに型定義を追加していきます。

```javascript:index.js
function add(x, y) {
    return x + y;
}
```

```typescript:index.d.ts
declare function add(x: number, y: number): number;
```

型定義ファイルは、拡張子が`.d.ts`のファイルとして作成し、JavaScriptコードの型定義を書いていきます。通常のTypeScriptファイルとは異なり、型定義以外を書くことはできません。

型定義の先頭には、`declare`キーワードを付ける必要があります。これは、型定義ファイルと一緒にトランスパイルされるファイルの内、どこかに型定義に対応する実装がありますということを、TypeScriptコンパイラに伝える役割があります。これを「アンビエント宣言」と言います。

型定義ファイルには実装を書くことはできないため、ファイル内の関数定義や変数定義には、全て`declare`を付けることになります。一方で、type alias定義やinterface定義は、実装ではないため、`declare`をつける必要はありません。

### Definitely Typed

開発でよく使われるnpmパッケージには、有志の方々が作ってくれた型定義ファイルが用意されていることが多いです。これらは、Definitely Typedプロジェクト([github](https://github.com/DefinitelyTyped/DefinitelyTyped))として管理されています。

Definitely Typedプロジェクトの型定義ファイルは、`npm install --save-dev @types/<package_name>`でインストールできます。型定義ファイルは、TypeScriptをトランスパイルした後には不要になるため、通常は`--save-dev`オプションを付けてインストールします。

### TypeScriptコンパイラで型定義ファイルを作る

余談にはなりますが、手元のTypeScriptファイルから、型定義だけを抜き出して、型定義ファイルを作成することもできます。

```shell
tsc -d 
```

## 型定義を拡張する

### Declaration Merge

新しく型定義ファイルを作る場合の他に、既存の型定義ファイルを拡張したい場合があります。型定義ファイルが自己の管理下にあるなら、ファイルを編集すれば良いだけですが、外部パッケージやDefinitely Typedで提供されている型定義を拡張したい場合は、型定義マージという仕組みを使って、型定義を編集します。

型定義マージを行うには、編集したい型定義と同じ名前の型定義を重複して作成します。有名な例としては、interface拡張や関数オーバーロードがあります。

```typescript
// interface拡張

interface Person {
    name: string;
}
interface Person {
    age: number;
}

const me: Person = {
    name: 'kts64',
    age: 20,
};

// 関数オーバーロード

function print(content: string): void;
function print(content: Error): void;

function print(content: string | Error): void {...}
```

さらに、種類が異なる型同士をマージすることも可能です。

例えば、クラスとnamespaceをマージすることで、クラス内に内部クラスを定義できます。

```typescript
namespace Employee {
  export class Name {...}
  export class Address {...}
}
class Employee {
  name: Employee.Name;
  address: Employee.Address;
}
```

## モジュール
### モジュールファイルとスクリプトファイル

TypeScriptのファイルは、その記載内容に基づいて、モジュールファイルとスクリプトファイルの2種類に区別されてコンパイルされます。ファイル内で、importやexportが一回でも使われていたらモジュールファイル、全く使われていなかったらスクリプトファイルとして扱われます。

二つのファイルの主な違いは、定義された変数のスコープです。モジュールファイルでは、スコープが同一ファイル内に閉じていますが、スクリプトファイルでは、グローバルスコープ (つまり、グローバル変数) になります。

importやexportを使う必要はないけど、モジュールファイルにしたい場合は、空exportを使うことが多いです。

```typescript
export {};
```

余談になりますが、スクリプトファイルの使用を禁止する`isolatedModules`というコンパイラオプションがあります。モジュールファイルとスクリプトファイルが混在するとややこしいので、通常は有効にしておいた方が良さげです。create-react-appなどで生成したプロジェクトでは、デフォルトで有効になっているらしいです。

### declare global

グローバル変数の型定義を行うために、`declare global`という特殊な文法が用意されています。この型定義の中に含まれる型定義が、グローバル変数の型定義として扱われます。

ここまで読んでくださった方は、スクリプトファイル内で型定義するのではダメなの？という疑問を持たれたかもしれません。それでも大丈夫です！この書き方は、モジュールファイル内でグローバル変数の型定義をしたい場合に使います。

```typescript
// 型定義 (index.d.ts)
declare global {
    var x: null;
}
export {};
```

### モジュールの型拡張

モジュールの型定義を行うときは、外部モジュールの場合は`declare module`を使います。

外部モジュール定義は、モジュールファイル内で行うか、スクリプトファイル内で行うかによって、なんと挙動が異なります！

モジュールファイル内で行う外部モジュール定義は、モジュール拡張 (module augmentation) と呼ばれ、その名の通り、既存のモジュール型定義を拡張するために利用されます。既存のモジュール型定義がない状態でモジュール拡張を行おうとすると、外部モジュールの呼び出しもとで型エラーとなります。

一方、スクリプトファイル内で行う外部モジュール定義は、アンビエント宣言 (ambient declaration) と呼ばれ、新しくモジュール型定義を追加するために利用されます。既存のモジュール型定義がある場合は、上書きされます。マージされないので注意が必要です。

前項で紹介した型定義マージと組み合わせることで、npmパッケージの型定義を拡張することができます。例えば、Reactを拡張したい場合は次のように書きます。型定義を拡張するためには、モジュールファイル内に型定義を書く必要があるので、空のexportを付けてあります。

```typescript
// 型定義 (react.d.ts)
export {};

declare module 'react' {
    export type X = null;
}

// 使用例 (App.ts)
import React from 'react';
const x: React.X = null;
```

### namespaceとモジュール

TypeScriptで扱うモジュールには、(古い文献では)内部モジュールと外部モジュールがありますと書かれています。これまで単に「モジュール」と呼んできたものは、外部モジュールにあたります。以降も、単に「モジュール」と書いたら、外部モジュールを指すことにします。

一方、内部モジュールとはnamespaceのことです。昔は、namespaceをモジュールと呼んでいたのです。TypeScript 1.5で用語が整理され、namespaceに改称となりました。互換性のために、今でもnamespaceをmoduleで定義することができます。

```typescript
// どちらでも内部モジュール定義ができます
module X {}
namespace X {}
```

## 練習問題 〜jestのカスタムマッチャ型定義〜

ここまで前提知識を確認してきましたが、いよいよjestのカスタムマッチャ型定義について見ていきましょう。

### カスタムマッチャとは？

jestについて、、、はご存知かと思いますが、カスタムマッチャについては馴染みのない方も多いと思いますので、簡単に紹介します。

jestはJavaScriptのテスティングフレームワークで、下記のようにテストを記述できます。

```typescript
describe('割り算', () => {
    test('商を求められる', () => {
        expect(6 / 2).toBe(3);
    });

    test('0で割るとエラー', () => {
        expect(() => ( 6 / 0 )).toThrow(Error);
    });
});
```

テストの中で使われている、`toBe`や`toThrow`が、マッチャと呼ばれるもので、テストの検査内容を指定しています。jestには様々なマッチャが用意されてはいるのですが、稀に標準のマッチャでは物足りないことがあり、そういう場合にカスタムマッチャを自分で定義します。

カスタムマッチャの詳しい定義の仕方は割愛しますが、例えば下記のように書けます。 (引用: [Jestドキュメント](https://jestjs.io/ja/docs/expect#expectextendmatchers))

```typescript
expect.extend({
  toBeWithinRange(actual, floor, ceiling) {...},
});

test('is within range', () => expect(100).toBeWithinRange(90, 110));
```

ここで問題になるのは、`expect(fuga).toBeWithinRange()`の部分で、何もしないと、`Property 'toBeWithinRange' does not exist on type 'JestMatchers<number>'.`とエラーが出てしまいます。

### 型定義ファイルを作る

さて、型定義ファイルを書いていきましょう。

やりたい事は、`expect(...)`という関数呼び出しの戻り値の型`JestMatchers`を拡張して、プロパティ`toBeWithinRange`を追加することです。

まず、型`JestMatchers`の定義を確認しましょう。どうやら、グローバル空間に`jest`というnamespaceが定義されていて、その中で`JestMatchers`というtype aliasが定義されているようです。

ところで、type aliasに対してはDeclaration Mergeを使うことができません。今回は、`JestMatchers`が同じnamespace内の`Matchers<R, T>`というinterfaceを含んでいることに注目して、`Matchers<R, T>`を拡張することで対応しましょう。

```typescript
type JestMatchers<T> = ... & Matchers<void, T> & ...
```

なので、型定義ファイルは、、、(jestの仕様上、`toBeWithinRange()`の戻り値は`Matchers<R, T>`の型引数`R`とします。)

<details>
<summary>解答例</summary>

```typescript: jest.d.ts
declare global {
    namespace jest {
        interface Matcher<R, T> {
            toBeWithinRange(floor: number, ceiling: number): R;
        }
    }
}

export {};
```

</details>

### 型定義ファイルのパス解決

ところで、型定義ファイルを用意したのは良いものの、TypeScriptコンパイラに使ってもらうにはどうすれば良いのでしょうか。

型定義ファイルを、tsconfig.jsonのあるフォルダか、そのサブフォルダに置いておくだけでOKです。TypeScriptコンパイラ (tscに限りますが) は、その範囲にあるファイルを全てコンパイル対象とするため、型定義ファイルも自動的に見つけてくれます。

一方で、ts-nodeを使う場合は工夫が必要です。ts-nodeはエントリーファイルからimportされていないファイルは、デフォルトではコンパイル対象に含まないので、型定義ファイルを見つけてくれません。

ts-nodeに型定義ファイルを読み込んでもらう方法はいくつかありますが、最も簡単なのは、tsconfig.jsonにts-node用の下記設定を追加することです。この設定により、ts-nodeもtscと同様に、全てのファイルをコンパイル対象にしてくれるようになります。

```json: tsconfig.json
{
  "ts-node": { "files": true },
  "compilerOptions": {
    ...
  }
}
```

そのほか、npmパッケージの型定義を上書きしたい場合などに限っては、tsconfig.jsonのpathsを設定することでも対処できます。

```json: tsconfig.json
{
  "compilerOptions": {
    "baseUrl": "./",
    "paths": {
      "react": ["src/@types/react/index"]
    }
  }
}
```

もちろん、普通にimportしても良いです。特に、型定義のimportでは、専用の`import type`構文が用意されています。そのほか、Three Slash Directivesという`/// <reference path=...>`を使う方法もありますが、近年は使われてなさそうなイメージです。

## サンプルコード

この記事で紹介したテクニックを使った簡単なサンプルコードを用意しています。
https://github.com/kanatatsu64/dts-test

TypeScriptコンパイラやjestの設定で悩んだ時などに、よろしければ参考にしてください。

## 参考資料
- [isolatedModules](https://typescriptbook.jp/reference/tsconfig/isolatedModules)
- [Triple Slash Directives](https://www.typescriptlang.org/docs/handbook/triple-slash-directives.html)
- [Declaration Merging](https://www.typescriptlang.org/docs/handbook/declaration-merging.html)
- [TypeScriptのdeclareやinterface Windowを勘で書くのをやめる2022](https://zenn.dev/qnighy/articles/9c4ce0f1b68350)