自分のTypeScriptプロジェクト専用に他人のNPMパッケージの型定義（アンビエント宣言）を書く

この記事は以下のような需要に応えようとするものです。

- 他人のNPMパッケージを自分のTypeScriptプロジェクトで使いたいが、型情報が付いていない
- [Definitely Typed](https://github.com/DefinitelyTyped/DefinitelyTyped)を探しても型情報がない
- 自分のパッケージ内で使う部分だけ型情報を付けたい
  - 依存パッケージ全体に型情報を書いてDefinitely Typedにコミットするほどのリソースがない

こうした需要に対して、`*.d.ts`の書き方（アンビエント宣言の文法など）についての解説はすぐにたくさん見つかりますが、型定義ファイルをプロジェクトの中でどう扱うか、tscやVS Codeに型情報を認識させるにはどうしたらいいかといった周辺的な情報を見つけるのに少し苦労したので、そのあたりをフォローしています。

2022年時点でパッケージ内にもDefinitely Typedにも型情報がないパッケージをTypeScriptプロジェクトで使うのはなるべく避けたいところですが、諸事情でやむを得ないこともあり、バッドノウハウのようなものかもしれません。

ただ、自分用から育ててあわよくば型情報を切り出してパッケージ本体やDefinitely Typedへの貢献を狙うこともできるでしょう。

## アンビエント宣言とは

TypeScriptには、JavaScriptソースと型情報を別々に提供するための、アンビエント宣言（ambient declaration）という仕組みがあります。

アンビエント宣言を記述したファイルには`*.d.ts`という拡張子が使われ、`*.js`ファイルとは別に用意されます。

## 型情報を書くパッケージ

この記事では[is-positive-integer](https://www.npmjs.com/package/is-positive-integer)という、関数を2つだけエクスポートしているパッケージを利用します[^postd-leftpad]。

[^postd-leftpad]: [NPMとleft-pad : 私たちはプログラミングのやり方を忘れてしまったのか？ | POSTD](https://postd.cc/npm-and-left-pad/) で知りました。

発展としてもう少し複雑な例を練習したくなったら、Definitely Typedに登録されているパッケージのリストからちょうどよいものを探したり、自分でJSを書いてもよいと思います。

## 下準備

この記事用に「自分のプロジェクト」と想定するNPMパッケージを作っていきます。説明のため色々省くので実用的ではありません。

`is-positive-integer`に依存し、関数を1つだけCommonJS形式でエクスポートするパッケージとします。

トランスパイルにはtypescript（tsc）だけを使い、rollupなどは使いません。

### npm init

```sh:
mkdir types-for-me
cd types-for-me
npm init
```

### npm install

```sh:
npm install -D typescript @types/node
npm install is-positive-integer
```

### tsconfig.json

シンプルな設定にとどめます。

```json:tsconfig.json
{
  "compilerOptions": {
    "target": "es2015",
    "module": "commonjs",
    "moduleResolution": "node",
    "declaration": true,
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true,
  },
  "files": [
    "index.ts",
  ],
}
```

トランスパイルされた`*.js`と`*.d.ts`は`dist/`ディレクトリの下に出力される設定です。

ファイル数が少ないのでTypeScriptソースを[filesキー](https://www.typescriptlang.org/ja/tsconfig#files)で指定していますが、globを使える[includeキー](https://www.typescriptlang.org/ja/tsconfig#include)で`"include": ["src/**/*.ts"]`のようにすることもできます。

### package.json

`package.json`に`main`、`types`、`scripts.build`を追加します。

```diff:
  {
    ...
+   "main": "dist/index.js",
+   "types: "dist/index.d.ts",
    "scripts": {
+     "build": "tsc",
      "test": "echo \"Error: no test specified\" && exit 1"
    },
    ...
  }
```

### index.ts

`is-positive-integer`をインポートして利用する（以外にほぼ意味のない）TypeScriptソースです。

```ts:index.ts
import isPositiveInteger from 'is-positive-integer'

export function posiOrNega(n: number): string {
  return isPositiveInteger(n) ? 'Positive!' : 'Not Positive!';
}
```

## 型情報を書く

やっと型情報を書く段階にきました。といっても関数2つなので大したことはありません。型情報ファイルの内容よりも、プロジェクトの設定にそのファイルをどう反映させるかや、パッケージ内でどう扱うかのほうがポイントです。

### is-positive-integer.d.ts

型情報ファイルを作ります[^dts-gen]。`types`というサブディレクトリを作り、`types/is-positive-integer.d.ts`とします。

[^dts-gen]: `npx dts-gen -m is-positive-integer.d.ts`で生成してもいい（Definitely Typedなどでは推奨されている）のですが、`is-positive-integer`はシンプルな構成で、かつ`dts-gen`の結果があまりうまくいかなかったので、ゼロから書きました。

```ts:types/is-positive-integer.d.ts
declare module 'is-positive-integer' {
  export default function isPositiveInteger(x: number): boolean;

  export function isSafePositiveInteger(x: number): boolean;
}
```

`declare module 'is-positive-integer' { ... }`の部分をアンビエントモジュール（ambient module）と呼び、`is-positive-integer`の型情報であることを宣言しています。波括弧の内側に関数のシグネチャを書き、`is-positive-integer`パッケージに属する関数についての情報だと示しています。もちろん場合によっては関数のほかに定数、クラス、インターフェースなども書けます。

この型情報ファイルを`tsconfig.json`に登録し、コンパイラに型情報の位置を伝えます。

```diff:tsconfig.json
 {
   "compilerOptions": {
     ...
   },
   "files": [
     "index.ts",
+    "types/is-positive-integer.d.ts",
   ],
 }
```

なお、`compilerOptions.typeRoots`は`import`するモジュールの型情報を示すためのオプションではなく、今回の場合、使ってもコンパイラに型情報は伝わりません（いかにも使えそうなオブション名ですが）。

`typeRoots`はグローバルライブラリ[^ts-global-lib]を参照する`/// <reference types="something" />`を使ったときに、参照モジュールを探索する場所を指定する設定です。

[^ts-global-lib]: グローバルライブラリについては https://www.typescriptlang.org/docs/handbook/declaration-files/templates/global-d-ts.html https://www.typescriptlang.org/docs/handbook/declaration-files/library-structures.html#consuming-dependencies などを参照。

`compilerOptions.typeRoots`については @tetradice さんが以下の記事で詳しくまとめてくださっています。

- [typeRootsの誤解 -- TypeScriptで、npmからインストールしたパッケージに型定義ファイル (*.d.ts) が存在しない場合の正しい対処方法](https://qiita.com/tetradice/items/b89a5dd41fcebf96379e) 

## 依存パッケージの型情報ファイルを公開するかどうか

`tsconfig.json`の`files`に`types/is-positive-integer.d.ts`を追加したことにより、型情報ファイルがビルド時に`compilerOptions.outDir`（今回は`dist/`）へコピーされることになります。

もし`npm publish`する場合、他パッケージから参照される`dist`ディレクトリに別パッケージの型情報を残してしまうのは行儀が良くなさそうです。今回はtscだけでトランスパイルしているので、`package.json`の`scripts.postbuild`に削除するコマンドを書いておくなどが考えられます。

依存しているパッケージのオブジェクトを、自分のバッケージで加工せず、自分のパッケージの利用者へ直接渡すようなケースもあるかもしれませんが、そういう場合は改めて自分のパッケージの型として公開するのがよいと思います。

## 型定義をテストする

`*.d.ts`の型定義のテストを書いていきます。

型定義テスト用のモジュールとしては[SamVerschueren/tsd](https://github.com/SamVerschueren/tsd)[^history-of-tsd]とMicrosoft謹製の[dtslint](https://github.com/microsoft/DefinitelyTyped-tools/tree/master/packages/dtslint)[^dt-tools]が挙げられますが、この記事ではtsdを使ったテストを書いてみます[^about-dtslint]。

[^history-of-tsd]: tsdという名前はもともと、型定義ファイルパッケージ管理を行う[DefinitelyTyped/tsd](https://github.com/DefinitelyTyped/tsd)で使われていましたが、[2016年にDefinitely Typedで非推奨とされ](https://github.com/DefinitelyTyped/tsd/issues/269)ました。代わりのパッケージ管理ツールとして[typings/typings](https://github.com/typings/typings)が出たのが非推奨の理由でしたが、2022年現在ではこのtypingsもdeprecatedとなっているようです。型テストライブラリとしてのtsdは[2018年から開発が始まり](https://github.com/SamVerschueren/tsd/commit/c11571b0c6d8bb0aaede5604a27da26db4f98ac6)、npm trendsによると[2021年4月を境にdtslintよりも人気に](https://npmtrends.com/dtslint-vs-tsd)なっています。

[^dt-tools]: リポジトリとしては[microsoft/DefinitelyTyped-tools](https://github.com/microsoft/DefinitelyTyped-tools/tree/master/packages/dtslint)の一部となっています。

[^about-dtslint]: dtslintは、[2019年にdeprecatedとなった](https://blog.palantir.com/tslint-in-2019-1a144c2317a9)TSLintに依存しており、やや不安を感じます。

テストランナーとしてJestを使い、tsdからアサーションをインポートして利用します。

### npm install

```sh:
npm install -D tsd jest @types/jest ts-jest
```

### package.json

`package.json`のトップレベルに`jest`というキーを追加して設定を書きます（独立ファイルにもできる）。

```diff:package.json
  {
    ...
    "scripts": {
      "build": "tsc",
-     "test": "echo \"Error: no test specified\" && exit 1"
+     "test": "jest"
    },
    ...
+   "jest": {
+     "preset": "ts-jest",
+     "verbose": true,
+     "moduleFileExtensions": [
+       "ts",
+       "js"
+     ],
+     "testMatch": [
+       "**/types/*.test-d.ts"
+     ]
+   }
+ }
```

### テストファイル

テストファイル`types/is-positive-integer.test-d.ts`を作るのですが、作ると同時に（中身を書く前に）`tsconfig.json`の`files`に同ファイル名を追加しておくと、VS Codeで補完が聞くようになり便利です。

`files`に`*.test-d.ts`を含めるとビルド時にトランスパイルされて`dist/`配下に`*.test-d.js`として出力されることになります。ここでも`package.json`の`script.postbuild`で削除するなどが選択肢になるでしょう。

```diff:tsconfig.json
  {
    "compilerOptions": {
      ...
    },
    "files": [
      "index.ts",
      "types/is-positive-integer.d.ts",
+     "types/is-positive-integer.test-d.ts",
    ],
  }
```

```ts:types/is-positive-integer.test-d.ts
import { expectType } from 'tsd';
import isPositiveInteger from 'is-positive-integer';

describe('isPositiveInteger', () => {
  it('returns boolean', () => {
    expectType<boolean>(isPositiveInteger(1));
    expectType<boolean>(isPositiveInteger(0));
  })
});
```

このテストは型のテストをしているというよりも関数が返す値の型をチェックしている状態ですが、ひとまず型の一致をテストできていることを確認してください。

テストを実行するには`num test`コマンドまたは`npm run test`コマンドを実行します。

### もう少しテストらしいテスト

前段のテストは「型のテスト」とはいえない感じだったので、TypeScriptに組み込まれている[Utility Types](https://www.typescriptlang.org/docs/handbook/utility-types.html)から`Required<Type>`のテストを書いてみます[^implementation-of-required]。このユーティリティ型は、OptionalなプロパティをOptionalではなく（＝必須に）します。例えば次のような型があるとして

[^implementation-of-required]: `Required<Type>`の実装は[ソース](https://github.com/microsoft/TypeScript/blob/main/src/lib/es5.d.ts#L1536)や @uhyo さんによる[TypeScriptの型入門のMapped Typesの節](https://qiita.com/uhyo/items/e2fdef2d3236b9bfe74a#mapped-types)などを参照。

```ts:
interface Foo {
  a?: number;
  b?: string;
}
```

この型を`Required<Foo>`すると、キー`a`と`b`に付いている`?`が外れて両方が必須となる型が得られます。

```ts:
import {
  expectType,
  expectAssignable,
  expectNotAssignable
} from 'tsd';

describe('Required', () => {
  it('makes props required', () => {
    interface Fixture {
      a?: number;
      b?: string;
    }
    expectType<Required<Fixture>>({
      a: 1,
      b: 'one',
    })
    expectAssignable<Required<Fixture>>({
      a: 1,
      b: 'one',
    });
    expectNotAssignable<Required<Fixture>>({
      a: 1,
    });
    expectNotAssignable<Required<Fixture>>({
      b: 'one',
    });
  });
});
```

## 参照

- [typeRootsの誤解 -- TypeScriptで、npmからインストールしたパッケージに型定義ファイル (*.d.ts) が存在しない場合の正しい対処方法 - Qiita](https://qiita.com/tetradice/items/b89a5dd41fcebf96379e)
- [TypeScript型定義ファイルのコツと生成ツール dtsmake - Qiita](https://qiita.com/ConquestArrow/items/450f961c3d54bc932cf3)
- [TypeScript: Documentation - .jsファイルから.d.tsファイルを生成する](https://www.typescriptlang.org/ja/docs/handbook/declaration-files/dts-from-js.html)
  - [TypeScript: Documentation - Creating .d.ts Files from .js files](https://www.typescriptlang.org/docs/handbook/declaration-files/dts-from-js.html)
- [TypeScript: TSConfig リファレンス - すべてのTSConfigのオプションのドキュメント](https://www.typescriptlang.org/ja/tsconfig)
  - [TypeScript: TSConfig Reference - Docs on every TSConfig option](https://www.typescriptlang.org/tsconfig)
- [tsdでTypeScriptの型定義とテストで戦う | Kumasan](https://kumaaaaa.com/tsd-type-unit-testing/)