TypeScript の型関数を簡単にテストする

## 0. 注意

[tsd](https://www.npmjs.com/package/tsd) のようなものをお求めの方は申し訳ありませんが、この記事では単に `tsc --noEmit ./test/` 相当のことをします。

## 1. 複雑な型定義に関する課題

TypeScript の制約をとても強くしていくと、かなり複雑な型関数が現れることがあります。

（この記事では）理解する必要はありませんが、例えばこんなのとか[^code1]

[^code1]: 引用元『[TypeScriptの型で遊ぶ時、再帰制限を（合法的に）突破する](https://qiita.com/kazatsuyu/items/44c1b012d66aae1dc2c0)』

```typescript
type ToTuple<T extends string, Result extends string[] = []> =
    T extends `${infer U}${infer V}`
    ? ToTuple<V, [...Result, U]>
    : Result;
```

あるいはこんなのとか[^code2]

[^code2]: 引用元（孫引き）『[conditional typeによる`DeepReadonly<T>` - TypeScriptの型入門](https://qiita.com/uhyo/items/e2fdef2d3236b9bfe74a#conditional-type%E3%81%AB%E3%82%88%E3%82%8Bdeepreadonlyt)』

```typescript
type DeepReadonly<T> =
    T extends any[] ? DeepReadonlyArray<T[number]> :
    T extends object ? DeepReadonlyObject<T> :
    T;

interface DeepReadonlyArray<T>
    extends ReadonlyArray<DeepReadonly<T>> {}

type DeepReadonlyObject<T> = {
    readonly [P in NonFunctionPropertyNames<T>]: DeepReadonly<T[P]>;
};

type NonFunctionPropertyNames<T> = {
    [K in keyof T]: T[K] extends Function ? never : K
}[keyof T];
```

このような複雑な型関数の挙動を一目で理解するのは TypeScript 素人にとって、とても難しいことです。

一番目の型関数は再帰制限を知らない人にもっと簡単な型に書き直されてしまうかもしれず、コメントを念入りに書く必要があるでしょう。

**「型の StoryBook[^storybook]」がほしい**ところです。

[^storybook]: [StoryBook](https://storybook.js.org/) とは、UI コンポーネントの見本（sample book）を作るライブラリです。React/Next, Vue/Nuxt, Svelte/Sapper など幅広く対応しています。

## 2. 型の StoryBook を作る――するとテストになる

### 2-1. 型の StoryBook を作る

先の `DeepReadonly` の挙動が確認できるように、いろいろなパターンを試してみましょう。

エラーをわざと出して、`@ts-expect-error` コメントで制御します[^ts-expect-error]。

[^ts-expect-error]: `@ts-expect-error` コメントでは、`@ts-ignore` コメントと違ってエラーがない場合エラーになります（公式リリースノート：[Documentation - TypeScript 3.9](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-9.html#-ts-expect-error-comments)）

```typescript:typebook/deepReadonly.ts
import type { DeepReadonly } from 'utility/types/deepReadonly';

type DeepArr = [number[], string[]];

// ふつうの Readonly<T>
type ReadonlyArr = Readonly<DeepArr>;
const arr: ReadonlyArr = [[], []];
// @ts-expect-error
arr[0] = [];
arr[1][0] = 'str';
// @ts-expect-error
arr.push(['str']);
arr[0].push(2);
arr[0][1] = 3;

// DeepReadonly<T>
type DeepReadonlyArr = DeepReadonly<DeepArr>;
const arr1: DeepReadonlyArr = [[], []];
// @ts-expect-error
arr1[0] = [];
// @ts-expect-error
arr1[1][0] = 'str';
// @ts-expect-error
arr1.push(['str']);
// @ts-expect-error
arr1[0].push(2);
// @ts-expect-error
arr1[0][1] = 3;
```

挙動が目に見える形になり、なんとなくどういう型関数なのかわかってきたのではないでしょうか？

今回は例に過ぎないのでこのくらいにしておきますが、さらに `Object` についても追加すると、より一層挙動がわかりやすくなるでしょう。

### 2-2. 型の StoryBook を自動テストにする

さて、これを自動テストにしていきましょう。

といっても、すでに `mocha` や `jest` 等を用いて適切に `.ts` ファイルの自動テストが適切に設定されていれば、とても簡単です。

そうでない場合もテストライブラリなしで書けます。

#### 2-2-1. すでに `ts-jest` 等を利用したテスト環境がある場合

ファイル拡張子に気をつけてください。

```typescript:typebook/deepReadonly.test.ts
import type { DeepReadonly } from 'utility/types/deepReadonly';

type DeepArr = [number[], string[]];

test('ふつうの Readonly<T>', () => {
    type ReadonlyArr = Readonly<DeepArr>;
    const arr: ReadonlyArr = [[], []];
    // @ts-expect-error
    arr[0] = [];
    arr[1][0] = 'str';
    // @ts-expect-error
    arr.push(['str']);
    arr[0].push(2);
    arr[0][1] = 3;
});

test('DeepReadonly<T>', () => {
    type DeepReadonlyArr = DeepReadonly<DeepArr>;
    const arr: DeepReadonlyArr = [[], []];
    // @ts-expect-error
    arr1[0] = [];
    // @ts-expect-error
    arr1[1][0] = 'str';
    // @ts-expect-error
    arr1.push(['str']);
    // @ts-expect-error
    arr1[0].push(2);
    // @ts-expect-error
    arr1[0][1] = 3;
});
```

簡単ですね。

#### 2-2-2. TypeScript 用テストライブラリを導入したくない場合

私は存じ上げませんが、さまざまな事情により、TypeScript 用のテストライブラリを導入したくない場合というのがあります。

その場合は普通にコンパイルしてしまいます。

```diff_json:package.json
   "scripts": {
+    "type-test": "tsc --noEmit ./typeBook"
```

ついでに GitHub Actions や Circle CI を使って自動テスト化してみましょう。

GitHub Actions のサンプルコードを置いておきます。

```yaml:.github/workflows/type-test.yaml
name: Type Test
on: [pull_request]
jobs:
  lint:
    runs-on: macos-10.15
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: '14'
          check-latest: true
      - uses: actions/cache@v2
        with:
          path: ~/.npm
          key: ${{ runner.OS }}-node-${{ hashFiles('package-lock.json') }}
          restore-keys: |
            ${{ runner.OS }}-node-
            ${{ runner.OS }}-
      - name: npm install
        run: npm ci
      - name: Type Test
        run: npm run type-test
```

## 3. まとめ

- TypeScript の型関数は StoryBook 的に使用例やエラー例を列挙すると挙動を把握しやすい
- エラーは `@ts-expect-error` で明示する
- 使用例を `test` 関数でラッピングするだけでテストになる