多重クリックを防止して型解決できるカスタムhook作るために型パズル解いた。

※Reactのアドベントカレンダーで投稿されていない日があったので2日遅れで代わりに投稿させてもらいます。


多重クリック(連打)をフロントエンド側の対処で防止するためのhookが調べた感じライブラリなさそうだったので作りました。

https://www.npmjs.com/package/mcp-hook

実態のコードは[30行ほど](https://github.com/en-ken/mcp/blob/main/src/index.ts)しかないコードなのでスニペットというレベルです。しかしながら、その作成過程で型パズルにハマって苦労したので、自分へのねぎらいも兼ねて書き記します。

## なにをしようとしたか

ボタンの多重クリック(連打)は基本的にUIにとって望ましい動作ではないケースが大半かと思います。
ボタンが連携するAPIが取得系などの冪等なAPIの場合は問題になりませんが、リソース作成などのAPIでは多重クリックされ意図しない数のリソースが作成されることになります。

リソースの多重作成にきちんと対処するにはバックエンドも含めた対応が必要ですが、
多重クリック自体に対処する方法としては、Reactでは以下のようなコードを書くノウハウが広く紹介されています。

```tsx
export const Sample: React.FC = () => {
  // ①処理中フラグ
  const isProcessing = useRef(false);

  const handleClick = async (foo: string) => {
    // ②処理中だったらなにもしない
    if (isProcessing.current) {
      return;
    }

    try {
      isProcessing.current = true; //　③処理前に処理中フラグを立てる
      await doSomethingAsync(foo); // API呼び出しなど
    } finally {
      isProcessing.current = false; //④必ずfalseにもどす
    }
  };

  return (
    <div>
      <button onClick={() => handleClick("foo")}>Click</button>
    </div>
  );
};
```
このノウハウについてはググればいくらでも解説が出てくるので説明は省きますが、ポイントはsetしても即時に値が反映されない`state`ではなく、`ref`を利用したフラグワークをする点です。
クラスコンポーネント時代であればクラスのフィールドで表現でよかったのに、ちょっと分かりづらいですね。

これを各イベント処理に記述すれば多重クリックは防ぐことができるわけですが、問題は**これが非常に横展開のしづらい記述**であることです。イベント処理の先頭や末尾にちょろっと書けば良い記述であれば、コピペ・置換がしやすいので容易に適用可能ですが、上記の記述の場合、

① ファンクショナルコンポーネントのhook定義
② イベント処理の先頭
③ イベント処理の中のtryブロックの先頭
④ イベント処理の中のfinallyブロックの先頭

と4つ別々の場所に記述する必要があります。これを間違えて「フラグをfalseに戻す」記述をfinallyブロックの外に書いてしまうと、例外発生時にfalseに戻されず、ボタン操作ができなくなるようなケースも考えられます。
### 共通化したい

こうしたややこしさを避けるためにも、いい感じに共通化したいというのが人情かなと思います。
イメージ的にはこんな感じでラップするだけで防止してくれると便利そう。
```tsx
const handleClick = useMCP(await doSomethingAsync("foo"))
```

「よっしゃ、`useRef`をラップしたカスタムhook作るで」となり、書いてみるわけですが、
```typescript
export const useMCP = (f: any): any => {
  const isProcessing = useRef(false);

  const multipleClickPreventer = async (...args: any) => {
    if (isProcessing.current) {
      return;
    }
    try {
      isProcessing.current = true;
      await f(...args);
    } finally {
      isProcessing.current = false;
    }
  };
  return multipleClickPreventer;
};
```
何も考えず諸々を`any`にすると当然型の恩恵を捨てることになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166181/ac114277-7611-b824-b41d-de656fe39619.png)

### 型解決したい

TypeScriptでは型をどこまで厳密にやるかというの派閥があるかと思いますが、私はわりと型厳密にちゃんと解決したい派です。「少なくともhookのI/F部分についてはany殺したい」マンなので、
「よっしゃ、型解決したるで」と意気揚々と始めたのですが思いのほか型パズルを解くのが大変で四苦八苦しました。

## 型パズルを解く

### Step1

引き受ける関数の条件を表現すると「0個以上の引数を受け取るasyncな関数」ということになります。
これを引数に表現すると以下のようになります。

```typescript
export const useMCP = (f: (...args: any[]) => Promise<any>) => { ... }
```

これで、hookに渡される値が以下のようになります。

![スクリーンショット 2022-12-25 21.38.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166181/ee7f3fbf-c023-d2ed-6633-46be3b4cca36.png)

asyncな関数であることは表現できるようになったものの、まだ関数の引数・戻り値にanyが残ってますね。解消していきましょう。

### Step2

hook自体の戻り値は↑のコードの通り、`multipleClickPreventer`というラッパー関数です。
したがって、このラッパー関数の引数・戻り値がhookの引数で与えられる`f`の引数・戻り値と一致するように解決してやる必要があります。

これを解決するには、TypeScriptが提供してくれている[Utility Types](https://www.typescriptlang.org/docs/handbook/utility-types.html)が非常に便利でした。
Utility Typesには関数の **引数を抽出する[ParameterType](https://www.typescriptlang.org/docs/handbook/utility-types.html#parameterstype)** と **戻り値を抽出する[ReturnType](https://www.typescriptlang.org/docs/handbook/utility-types.html#returntypetype)** が組み込み関数として提供されています。これを適用してやります。

```typescript
export const useMCP = <F extends (...args: any[]) => Promise<any>>(f: F) => {
  const isProcessing = useRef(false);

  const multipleClickPreventer = async (
    ...args: Parameters<F>
  ): ReturnType<F> => {
    ...
  }
  return multipleClickPreventer
}
```

これで終わりと行きたいところですが、ところがこれだとうまくいきません。戻り値のところで以下のエラーが発生します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166181/433997d4-dba6-ec49-52c2-aae9128505fb.png)
「async関数の戻り値は`Promise<T>`型じゃないといけない。`Promise<any>`ってこと？」という感じのエラーが出ます。でも、ちゃんと`f`の戻り値は`Promise<T>`型なんですよねぇ。。。

イベント関数で戻り値を扱うことはまずないように思うので、`Promise<any>`にしちゃってもよい気はしたのですが、もう少し頑張って調べてみると、以下のようなIssueが報告されていました。(でも自動クローズされてしまっている... :cry: )

https://github.com/Microsoft/TypeScript/issues/23114

ざっくり要約すると、どうも**async関数において、明示的に戻り値のPromise型の`<T>`部分を暗黙的に推論することができない**ということのようです。
執筆時点での最新版である`typescript@4.9.4`においてもこの問題は発生しており、現状解決する気配はなさそう :rolling_eyes: 

### Step3

バグじゃんと思うわけですが、現状推論できないのであれば、どうにか回避を考えてみます。
`Promise<T>`の`T`を暗黙的に推論できないのであれば、明示的に与えられないかを考えます。
すなわち、

1. `f`の戻り値の`Promise<T>`の`T`を明示的に抽出
1. 1.で抽出した`T`を`Promise`に再度ラップしたものを戻り値の型に設定

を考えることにしました。

「`f`の戻り値の`Promise<T>`の`T`を明示的に抽出」をするにはどうしたらよいか考える上で、`ReturnType`の実装が非常に参考になりました。
ご存知の方多いと思いますが、Utility Typesの便利群はすべてTypeScriptで実装されています。`ReturnType`だと

```typescript
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
```

となっています。`infer`などの言語仕様についてはググれば色々出てくるので割愛しますが、これを読み取ると、`ReturnType`は「**`<>`カッコ内で関数(`T`)のみを受け取り、その関数`T`の戻り値(`R`)が推論できるとき、`R`を返す**」という実装になってます。

これと同様の方法で戻り値の`Promise`でラップされた値が取れるのでは？と考えました。
すなわち、「**`<>`カッコ内で `Promise`型のみを受け取り、その`Promise<R>`の`R`が推論できるとき、`R`を返す**」という型抽出です。
型抽出の実装は以下になります。

```typescript
type AwaitType<T extends Promise<any>> = T extends Promise<infer R> ? R : never;
```

:::note info
**2022/12/27 追記**
Utility Types眺めていたら[Awaited Type](https://www.typescriptlang.org/docs/handbook/utility-types.html#awaitedtype)あるじゃん :scream:
4.5から追加されてるみたいですね。車輪の再発明(しかもクオリテイ低い)でした。。。
:::


そして、これを使ってラッパー関数の戻り値の定義は以下のように修正しました。

```typescript
export const useMCP = <F extends (...args: any[]) => Promise<any>>(f: F) => {
  const isProcessing = useRef(false);

  const multipleClickPreventer = async (
    ...args: Parameters<F>
  ): Promise<AwaitType<ReturnType<F>> | undefined> => {
    ...
  }
  return multipleClickPreventer
}
```

`ReturnType`で取得された戻り値の`Promise`を再度`AwaitType`で推論して同期処理時の戻り値を抽出し、それを`Promise`で再度ラップするということをやっています。
これにより、ちゃんと戻り値が返される場合も解決できるようになりました。(まぁ使わないけど。)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166181/6f3ac069-1ee6-79bf-c2e0-2d6ddf4156d7.png)

## まとめ

ということで、多重クリックを防止するhookを作った過程でハマった型パズルの問題を紹介しました。
TypeScriptの型Generic絡みだすととたんにムズいなぁという印象＆謎挙動で割と大変でした。
サラッと書いていますが、それなりにハマってそれなりの時間を溶かしております。
ツッコミあったらお願いします。
では、皆様素敵な年末を。