TypeScript: 部分的なPromise型を実行時エラーなく実装するテクニック

TypeScriptで非同期処理を書いていると、「オブジェクトの一部のプロパティだけをPromiseにしたい」というパターンに遭遇することがあります。このパターンは一見シンプルに見えますが、実装を誤ると実行時に`Unhandled Rejection`という問題を引き起こす可能性があります。

本稿では、このパターンを安全に実装する方法について解説します。

## 非同期処理の部分適用パターン

まず、シンプルなタスク処理の例で考えてみましょう：

```typescript:type.ts
// タスク結果の型
export type TaskResult = {
    status: string;
    value: number;
};

// 部分的にPromiseにした型
export type AsyncTask = {
    status: Promise<string>;
    value: Promise<number>;
    result: Promise<TaskResult>;
};
```

## よくある実装の問題点

このパターンを単純に実装すると、次のような実行時の問題が発生します：

```typescript:problem.ts
import type { AsyncTask, TaskResult } from "./type";

// unhandledRejectionをキャッチして表示
process.on("unhandledRejection", (reason) => {
    console.log("Unhandled Rejection:", (reason as Error).message);
});

// ❌ 問題のある実装
function runTask(): AsyncTask {
    const resultPromise = new Promise<TaskResult>((resolve, reject) => {
        setTimeout(() => {
            reject(new Error("タスク失敗！"));
        }, 1000);
    });

    return {
        status: resultPromise.then(result => result.status),
        value: resultPromise.then(result => result.value),
        result: resultPromise
    };
}

// 問題のある使用例
const task = runTask();
// try-catchしているはずなのに、Unhandled Rejectionが発生する↓
try {
    const status = await task.status;
    console.log(status);
} catch (error) {
    console.error("try-catchでキャッチしたエラー:", (error as Error).message);
}
```

実行結果：

```
try-catchでキャッチしたエラー: タスク失敗！
Unhandled Rejection: タスク失敗！
```

この実装には、使用するプロパティをtry-catchで囲んでも、他のプロパティでも`Unhandled Rejection`が発生する（上記の実行結果のように2つのエラーが表示される）という問題があります。

## 安全な実装パターン

これらの問題を解決する実装を見ていきましょう：

```typescript:solution.ts
import type { AsyncTask, TaskResult } from "./type";

// unhandledRejectionをキャッチして表示
process.on("unhandledRejection", (reason) => {
    console.log("Unhandled Rejection:", (reason as Error).message);
});

// ✅ 改善された実装
function runTask(): AsyncTask {
    const resultPromise = new Promise<TaskResult>((resolve, reject) => {
        setTimeout(() => {
            reject(new Error("タスク失敗！"));
        }, 1000);
    });

    // 重要: 元のPromiseに空のcatchハンドラーを追加
    resultPromise.catch(() => {});

    // getterを使用して返す
    return {
        get status() {
            return resultPromise.then(result => result.status);
        },
        get value() {
            return resultPromise.then(result => result.value);
        },
        get result() {
            return resultPromise;
        }
    };
}

// 使用例
const task = runTask();
try {
    const status = await task.status;
    console.log(status);
} catch (error) {
    console.error("try-catchでキャッチしたエラー:", (error as Error).message);
}
```

実行結果：

```
try-catchでキャッチしたエラー: タスク失敗！
```

改善された実装では、エラーは1回だけ表示され、使用していないプロパティでUnhandled Rejectionは発生しません。

## 安全な実装のポイント

この実装パターンには2つの重要な工夫があります：

### 1. 元のPromiseへの空のcatchハンドラーの追加

```typescript
resultPromise.catch(() => {});
```

これにより、Unhandled Rejectionを防ぎます。

これだけだと不十分で、後述の「2. getterの使用」が必要です。もし、この工夫だけだと、使用していないプロパティでもエラーが発生します：

```typescript:problem2.ts
import type { AsyncTask, TaskResult } from "./type";

// unhandledRejectionをキャッチして表示
process.on("unhandledRejection", (reason) => {
    console.log("Unhandled Rejection:", (reason as Error).message);
});

function runTask(): AsyncTask {
    const resultPromise = new Promise<TaskResult>((resolve, reject) => {
        setTimeout(() => {
            reject(new Error("タスク失敗！"));
        }, 1000);
    });

    // 重要: 元のPromiseに空のcatchハンドラーを追加
    resultPromise.catch(() => {});

    return {
        status: resultPromise.then(result => result.status),
        value: resultPromise.then(result => result.value),
        result: resultPromise
    };
}

// 問題のある使用例
const task = runTask();
try {
    const status = await task.status;
    console.log(status);
} catch (error) {
    console.error("try-catchでキャッチしたエラー:", (error as Error).message);
}
```

実行結果：

```
try-catchでキャッチしたエラー: タスク失敗！
Unhandled Rejection: タスク失敗！
```

catchハンドラーだけでは、エラーが2回表示され、問題が解決していないことがわかります。

### 2. getterの使用

```typescript
get status() { ... }
```

遅延評価を実現し、必要なときだけPromiseを評価します。

getterだけでも不十分です。以下のように、プロパティにアクセスしない場合、Unhandled Rejectionが発生してしまいます：

```typescript:problem3.ts
import type { AsyncTask, TaskResult } from "./type";

// unhandledRejectionをキャッチして表示
process.on("unhandledRejection", (reason) => {
    console.log("Unhandled Rejection:", (reason as Error).message);
});

function runTask(): AsyncTask {
    const resultPromise = new Promise<TaskResult>((resolve, reject) => {
        setTimeout(() => {
            reject(new Error("タスク失敗！"));
        }, 1000);
    });

    // resultPromise.catch(() => {});

    return {
        get status() {
            return resultPromise.then(result => result.status);
        },
        get value() {
            return resultPromise.then(result => result.value);
        },
        get result() {
            return resultPromise;
        }
    };
}

// taskのプロパティーに触れないと……
const task = runTask();
```

実行結果：

```
Unhandled Rejection: タスク失敗！
```

getterは個々のプロパティの遅延評価を実現しますが、元のPromiseのエラーハンドリングは行いません。そのため、プロパティにアクセスしなくても、元のPromiseがrejectされるとUnhandled Rejectionが発生してしまいます。

## まとめ

部分的にPromiseな型を扱う際は：

1. `Promise.then`で必要な値を取り出す
2. 元のPromiseに空の`catch`ハンドラーを追加して、Unhandled Rejectionを防ぐ
3. `getter`を使って遅延評価を実現する

これらの工夫により、実行時エラーを防ぎ、`Promise`を部分的に扱えるようになります。特に、catchハンドラーと遅延評価の組み合わせが重要で、catchハンドラーは元のPromiseのエラーを処理し、getterは個々のプロパティの遅延評価を実現します。

---

最後までお読みくださりありがとうございました。この投稿が少しでもお役に立てば幸いです。Twitterでは、Qiitaに書かない技術ネタなどもツイートしているので、よかったらフォローお願いします→[@suin](https://twitter.com/suin)