【TypeScript】setTimeout 関数を、キャンセル可能な Promise を返す関数にする

[`setTimeout` 関数](https://developer.mozilla.org/ja/docs/Web/API/WindowTimers/setTimeout) はコールバック方式であり使い勝手が悪いので
これを [`Promise`](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Promise) 化する。
キャンセルできるようにもする。

## 準備：型定義

`Promise` オブジェクトとキャンセル関数を次のようなオブジェクトにまとめて返してもよいが、

```ts
{
    promise: Promise,
    cancel: () => void,
}
```

これだと使う際にひと手間かかる。

そこで、`Promise` に `cancel()` メソッドを追加した `CancellablePromise` 型を定義し、
その型のオブジェクトを返すことにする。
こうすれば、呼び出し側でキャンセルを使わない場合には単なる `Promise` として扱うことができる。

```ts
/**
 * キャンセル可能なオブジェクトのインターフェイス。
 */
interface Cancellable {
  cancel(): void;
}

/**
 * キャンセル可能な Promise。
 * 
 * キャンセルするためのメソッド cancel() を持つ。
 * cancel() を呼び出すと Promise が reject される。
 * このときの reason には Cancelled クラスのインスタンスが渡される。
 */
type CancellablePromise<T> = Promise<T> & Cancellable;
```

オブジェクトに `cancel` メソッドを追加する際にはどうしても `any` 型へのキャストが必要になる。
それを隠蔽する関数を定義する。

```TypeScript
/**
 * 指定されたオブジェクトに cancel メソッドを追加して Cancellable にする。
 * 
 * @param base Cancellable にするオブジェクト。
 * @param cancel cancel メソッドとして base のメンバに追加する関数。
 */
function toCancellable<T>(
  base: T,
  cancel: () => void
): T & Cancellable {
  const baseAsAny = base as any;
  baseAsAny.cancel = cancel;
  return baseAsAny;
}
```

`CancellablePromise` がキャンセルされたときには、キャンセルされずにタイムアウトしたときと区別できるよう、`reject` することにしたい。
キャンセルによる `reject` は、それ以外の理由による `reject` と区別できるようにしたい。

そのため、キャンセルによる `reject` での reason として使うクラスを定義する。

```ts
/**
 * CancellablePromise がキャンセルされたときに
 * reject の reason として渡されるオブジェクトのクラス。
 */
class CancellablePromiseCancelled { }
```

## 関数実装

```ts
/**
 * 指定された時間の後に resolve される、キャンセル可能な Promise を生成して返す。
 * 
 * @param delay 返した Promise が resolve されるまでの時間[ミリ秒]。
 */
function timeoutAsync(delay: number): CancellablePromise<void> {
  // setTimeout 関数が返した ID。
  // 指定時間が経過するかキャンセルされたら null。
  let timeoutID: number | null = null;
  let rejectPromise: (reason: CancellablePromiseCancelled) => void
    = (_) => { }; // non-nullable 型にするため、ダミーの null オブジェクトで初期化する。

  const promise = new Promise<void>((resolve, reject) => {
    timeoutID = setTimeout(() => {
      timeoutID = null;

      resolve();
    }, delay);
    rejectPromise = reject;
  });

  const cancel = () => {
    if (timeoutID !== null) {
      clearTimeout(timeoutID);
      timeoutID = null;

      rejectPromise(new CancellablePromiseCancelled());
    }
  };

  return toCancellable(promise, cancel);
}
```

## 動作確認

### キャンセルなし

```ts
(async () => {
    const beginAt = performance.now();
    await timeoutAsync(1_000);
    const endAt = performance.now();
    console.log(`${endAt - beginAt}`); // 約 1,000 が出力される。
})();
```

約 1,000 の数値が出力されることから、正常に動作していることが確認できる。

### キャンセルあり

```ts
(async () => {
    // 2秒後に resolve される Promise を生成する。
    const promise = timeoutAsync(2_000);

    // 1秒経過した時点でキャンセルする。
    (async () => {
        await timeoutAsync(1_000);
        promise.cancel();
    })();

    try {
        await promise;
    } catch (e) {
        if (e instanceof CancellablePromiseCancelled) {
            console.log("promise はキャンセルされました。"); // > promise はキャンセルされました。
        }
    }
})();
```

2秒後に `resolve` される `Promise` を生成し、1秒経過した時点でそれをキャンセルしている。
キャンセルされた `Promise` は、それを `await` した際に `CancellablePromiseCancelled` オブジェクトをスローする。

`promise はキャンセルされました。` と出力されることから、正常に動作していることが確認できる。

## 補足

`CancellablePromiseCancelled` クラスに、どの関数で生成した `CancellablePromise` がキャンセルされたのかなどが分かるよう情報を追加してもよいだろう。
派生クラスで追加してもよい。

／以上