3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

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

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

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

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

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

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

よくある実装の問題点

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

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つのエラーが表示される)という問題があります。

安全な実装パターン

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

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ハンドラーの追加

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

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

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

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の使用

get status() { ... }

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

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

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

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?