TypeScriptで非同期処理を型安全に書く使い方 Promiseとasync awaitの型定義を整理
TypeScript で非同期処理を書いていて、「戻り値の型が any になってしまう」「catch ブロックでエラーの型が unknown になり扱いに困る」といった経験はありませんか。本記事では、Promise の戻り値設計と例外設計を整理し、型推論を活かした安全な非同期コードの書き方を解説します。初学者から実務者まで、非同期処理の型定義で迷わないための判断基準を提供します。
Promise と async/await の型定義における比較
| 観点 | Promise チェーン | async/await |
|---|---|---|
| 戻り値の型定義 | Promise<T> を明示 | 関数に Promise<T> を明示、または型推論に任せる |
| エラーの型 | .catch() 内は unknown | catch ブロック内は unknown |
| 型推論の効きやすさ | チェーン途中で途切れやすい | 変数代入時に推論が効きやすい |
| ジェネリクスとの相性 | 型引数の受け渡しがやや冗長 | 自然に型引数を渡せる |
| 可読性 | ネストが深くなりやすい | 同期的な見た目で読みやすい |
| 実務での採用傾向 | レガシーコードや特殊なケース | 現在の主流 |
この後、それぞれの詳細と使い分けの判断基準を解説します。
検証環境
- OS: macOS Sequoia 15.2
- Node.js: 22.13.0
- TypeScript: 5.7.3
- 主要パッケージ:
- @types/node: 22.10.7
- 検証日: 2026 年 01 月 18 日
Promise の型定義が曖昧になる背景
TypeScript で非同期処理を書く際、Promise の型定義が曖昧になりやすい理由を理解しておくことが重要です。
fetch API と JSON パースの型推論の限界
JavaScript の fetch API を使う場合、response.json() の戻り値は Promise<any> となります。これは JSON の構造が実行時まで不明なため、TypeScript が型を推論できないことに起因します。
typescript// fetch の戻り値は any になる
async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`);
return response.json(); // Promise<any> として推論される
}
この any が後続の処理に伝播し、型安全性が失われる原因となります。
つまずきやすい点:
response.json()はPromise<any>を返すため、明示的な型定義なしでは TypeScript の恩恵を受けられません。
外部データの型が保証されない問題
API からのレスポンスやファイルから読み込んだ JSON は、コンパイル時に型を保証できません。TypeScript の型システムはあくまでコンパイル時の静的チェックであり、実行時のデータ構造を検証する仕組みではないためです。
以下の図は、TypeScript の型チェックと実行時の関係を示しています。
mermaidflowchart LR
source["ソースコード"] --> compile["コンパイル時<br/>型チェック"]
compile --> js["JavaScript"]
js --> runtime["実行時"]
api["外部API"] --> runtime
runtime --> result["結果"]
compile -.->|"型は消える"| js
api -.->|"型保証なし"| runtime
この図が示すように、コンパイル後の JavaScript には型情報が残りません。外部 API からのデータは実行時に初めて取得されるため、コンパイル時の型チェックの対象外となります。
非同期処理の型定義で発生する課題
実際の開発現場で遭遇する非同期処理の型に関する課題を整理します。
戻り値の型が any になり型推論が効かない
最も多い課題は、非同期関数の戻り値が any になってしまうケースです。
typescript// 問題:型が any になる
async function fetchData(url: string) {
const response = await fetch(url);
return response.json(); // any
}
// 呼び出し側でも any のまま
const data = await fetchData("/api/users");
console.log(data.name); // 補完が効かない、タイポに気づけない
型推論が効かない状態では、IDE の補完機能も働かず、プロパティ名のタイポや存在しないプロパティへのアクセスがコンパイルエラーにならない問題があります。
catch ブロックのエラー型が unknown になる
TypeScript 4.4 以降、catch ブロックのエラーは unknown 型として扱われます。これにより型安全性は向上しましたが、エラーオブジェクトのプロパティに直接アクセスできなくなりました。
typescriptasync function riskyOperation() {
try {
const result = await someAsyncTask();
return result;
} catch (error) {
// error は unknown 型
console.log(error.message); // エラー: 'unknown' 型に 'message' は存在しない
}
}
つまずきやすい点:
errorがunknown型のため、instanceofや型ガードで絞り込まないとプロパティにアクセスできません。
Promise.all での型推論の複雑化
複数の非同期処理を並行実行する Promise.all では、各 Promise の型がタプルとして推論されます。型注釈がないと意図しない型になることがあります。
typescript// 各関数の戻り値型が明確でないと推論が失敗する
async function loadDashboard(userId: string) {
const [user, posts, settings] = await Promise.all([
fetchUser(userId), // any
fetchPosts(userId), // any
fetchSettings(userId), // any
]);
// user, posts, settings すべて any になる
}
型安全な非同期処理の実装方針
前述の課題を解決するための実装方針を解説します。
戻り値型の明示的な定義
非同期関数には、戻り値の型を明示的に定義することを推奨します。
typescript// ユーザー型の定義
interface User {
id: string;
name: string;
email: string;
createdAt: string;
}
// 戻り値型を明示的に定義
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
return response.json() as User;
}
Promise<User> と明示することで、呼び出し側でも User 型として扱えます。ただし、as User は型アサーションであり、実行時の検証は行われない点に注意が必要です。
型ガードによる実行時検証
外部データの型を実行時に検証するには、型ガード関数を使用します。
typescript// 型ガード関数
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value &&
"email" in value &&
typeof (value as User).id === "string" &&
typeof (value as User).name === "string" &&
typeof (value as User).email === "string"
);
}
// 型ガードを使った安全な実装
async function fetchUserSafe(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
const data: unknown = await response.json();
if (!isUser(data)) {
throw new Error("Invalid user data format");
}
return data; // この時点で User 型として確定
}
型ガードを通過した後の data は TypeScript により User 型として認識されます。
エラー型の明示的な設計
エラーハンドリングでは、カスタムエラークラスを定義して型を明確にします。
typescript// カスタムエラークラス
class ApiError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly code?: string,
) {
super(message);
this.name = "ApiError";
}
}
// 型ガード
function isApiError(error: unknown): error is ApiError {
return error instanceof ApiError;
}
function isError(error: unknown): error is Error {
return error instanceof Error;
}
// エラーハンドリングの実装
async function fetchWithErrorHandling<T>(
url: string,
validator: (data: unknown) => data is T,
): Promise<T> {
try {
const response = await fetch(url);
if (!response.ok) {
throw new ApiError(
`Request failed: ${response.statusText}`,
response.status,
);
}
const data: unknown = await response.json();
if (!validator(data)) {
throw new ApiError(
"Invalid response format",
response.status,
"INVALID_FORMAT",
);
}
return data;
} catch (error) {
if (isApiError(error)) {
console.error(`API Error [${error.status}]: ${error.message}`);
throw error;
}
if (isError(error)) {
throw new ApiError(error.message, 0, "NETWORK_ERROR");
}
throw new ApiError("Unknown error occurred", 0, "UNKNOWN");
}
}
この実装では、ジェネリクス <T> と型ガード validator を組み合わせることで、任意の型に対して型安全なフェッチ処理を実現しています。
Promise の型定義と async/await の使い分け
Promise チェーンと async/await それぞれの特徴と、実務での使い分けを解説します。
Promise チェーンの型定義
Promise チェーンでは、各 .then() の戻り値が次の処理に渡されます。
typescriptinterface Post {
id: string;
title: string;
content: string;
authorId: string;
}
// Promise チェーンでの実装
function fetchPostWithAuthor(
postId: string,
): Promise<{ post: Post; author: User }> {
return fetch(`/api/posts/${postId}`)
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<Post>;
})
.then((post) => {
return fetch(`/api/users/${post.authorId}`)
.then((response) => response.json() as Promise<User>)
.then((author) => ({ post, author }));
});
}
Promise チェーンは、処理の流れを関数型のスタイルで記述できますが、ネストが深くなると可読性が低下します。
async/await での型定義
同じ処理を async/await で書くと、より直感的になります。
typescript// async/await での実装
async function fetchPostWithAuthorAsync(
postId: string,
): Promise<{ post: Post; author: User }> {
const postResponse = await fetch(`/api/posts/${postId}`);
if (!postResponse.ok) {
throw new Error(`HTTP ${postResponse.status}`);
}
const post = (await postResponse.json()) as Post;
const authorResponse = await fetch(`/api/users/${post.authorId}`);
if (!authorResponse.ok) {
throw new Error(`HTTP ${authorResponse.status}`);
}
const author = (await authorResponse.json()) as User;
return { post, author };
}
async/await では、各ステップで変数に代入するため、TypeScript の型推論が効きやすくなります。
実務での選択基準
検証の結果、以下の基準で使い分けることを推奨します。
| 状況 | 推奨アプローチ | 理由 |
|---|---|---|
| 新規実装 | async/await | 可読性が高く、型推論が効きやすい |
| 単純な変換処理 | Promise チェーン | .then(data => transform(data)) のような単純な変換は簡潔に書ける |
| エラーハンドリングが複雑 | async/await | try-catch で分岐しやすい |
| 既存コードの保守 | 既存スタイルに合わせる | 一貫性を優先 |
Promise.all と Promise.allSettled の型安全な使い方
複数の非同期処理を並行実行するパターンを解説します。
Promise.all のジェネリクスを活かした実装
Promise.all はタプル型を返すため、各要素の型が保持されます。
typescriptinterface Settings {
theme: "light" | "dark";
language: string;
notifications: boolean;
}
// 型安全な Promise.all の使用
async function loadUserDashboard(userId: string): Promise<{
user: User;
posts: Post[];
settings: Settings;
}> {
// 各関数の戻り値型が明確なら、Promise.all の型推論が正しく機能する
const [user, posts, settings] = await Promise.all([
fetchUser(userId), // Promise<User>
fetchUserPosts(userId), // Promise<Post[]>
fetchUserSettings(userId), // Promise<Settings>
]);
// user: User, posts: Post[], settings: Settings として推論される
return { user, posts, settings };
}
呼び出す関数の戻り値型が明確に定義されていれば、Promise.all の型推論は正しく機能します。
Promise.allSettled で部分的な成功を扱う
一部の処理が失敗しても他の結果を取得したい場合は、Promise.allSettled を使用します。
typescriptinterface BatchResult<T> {
successful: T[];
failed: { index: number; reason: string }[];
}
async function fetchUsersInBatch(
userIds: string[],
): Promise<BatchResult<User>> {
const results = await Promise.allSettled(userIds.map((id) => fetchUser(id)));
const successful: User[] = [];
const failed: { index: number; reason: string }[] = [];
results.forEach((result, index) => {
if (result.status === "fulfilled") {
successful.push(result.value);
} else {
failed.push({
index,
reason:
result.reason instanceof Error
? result.reason.message
: String(result.reason),
});
}
});
return { successful, failed };
}
Promise.allSettled の戻り値は PromiseSettledResult<T>[] 型で、fulfilled または rejected の判別付きユニオン型として扱えます。
以下の図は、Promise.all と Promise.allSettled の挙動の違いを示しています。
mermaidflowchart TB
subgraph all["Promise.all"]
a1["Promise 1"] --> aresult["結果"]
a2["Promise 2"] --> aresult
a3["Promise 3 (失敗)"] --> afail["即座に reject"]
afail -.->|"他の結果は破棄"| aresult
end
subgraph settled["Promise.allSettled"]
s1["Promise 1"] --> sresult["すべての結果を配列で返す"]
s2["Promise 2"] --> sresult
s3["Promise 3 (失敗)"] --> sresult
end
Promise.all はいずれかが失敗すると即座に reject されますが、Promise.allSettled はすべての結果を待ちます。
ジェネリクスを活用した汎用的な非同期ユーティリティ
再利用可能な非同期処理のユーティリティを、ジェネリクスを活用して実装します。
型安全なフェッチ関数
typescript// 汎用的なフェッチ関数
async function typedFetch<T>(
url: string,
options: RequestInit = {},
validator?: (data: unknown) => data is T,
): Promise<T> {
const response = await fetch(url, {
headers: {
"Content-Type": "application/json",
...options.headers,
},
...options,
});
if (!response.ok) {
throw new ApiError(
`HTTP ${response.status}: ${response.statusText}`,
response.status,
);
}
const data: unknown = await response.json();
if (validator && !validator(data)) {
throw new ApiError(
"Response validation failed",
response.status,
"VALIDATION_ERROR",
);
}
return data as T;
}
// 使用例
const user = await typedFetch<User>("/api/users/1", {}, isUser);
const posts = await typedFetch<Post[]>("/api/posts");
validator を省略した場合は型アサーションのみ、渡した場合は実行時検証も行います。
リトライ機能付きの非同期実行
typescriptinterface RetryOptions {
maxAttempts: number;
delayMs: number;
backoffMultiplier?: number;
}
async function withRetry<T>(
operation: () => Promise<T>,
options: RetryOptions,
): Promise<T> {
const { maxAttempts, delayMs, backoffMultiplier = 2 } = options;
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempt < maxAttempts) {
const delay = delayMs * Math.pow(backoffMultiplier, attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
throw lastError;
}
// 使用例
const user = await withRetry(() => fetchUser("123"), {
maxAttempts: 3,
delayMs: 1000,
});
ジェネリクス <T> により、渡した関数の戻り値型がそのまま withRetry の戻り値型になります。
タイムアウト付き Promise
typescriptclass TimeoutError extends Error {
constructor(
message: string,
public readonly timeoutMs: number,
) {
super(message);
this.name = "TimeoutError";
}
}
async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
): Promise<T> {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(
new TimeoutError(`Operation timed out after ${timeoutMs}ms`, timeoutMs),
);
}, timeoutMs);
});
return Promise.race([promise, timeoutPromise]);
}
// 使用例
try {
const user = await withTimeout(fetchUser("123"), 5000);
} catch (error) {
if (error instanceof TimeoutError) {
console.error(`Timeout: ${error.timeoutMs}ms`);
}
}
Promise.race を使い、本来の Promise とタイムアウト Promise のどちらか早い方の結果を返します。
Promise の戻り値設計と例外設計の比較まとめ
これまで解説した内容を踏まえ、設計判断の指針をまとめます。
戻り値設計の比較
| 設計パターン | 型安全性 | 実行時安全性 | 実装コスト | 適用場面 |
|---|---|---|---|---|
型アサーション (as T) | 中 | 低 | 低 | 信頼できる API、プロトタイプ |
| 型ガード関数 | 高 | 高 | 中 | 外部 API、ユーザー入力 |
| バリデーションライブラリ (Zod 等) | 高 | 高 | 中 | 複雑なスキーマ、チーム開発 |
| 型推論に任せる | 低 | 低 | 低 | 推奨しない |
例外設計の比較
| 設計パターン | 型安全性 | エラー情報の詳細度 | 実装コスト | 適用場面 |
|---|---|---|---|---|
| 標準 Error | 低 | 低 | 低 | 小規模、単純なエラー |
| カスタムエラークラス | 高 | 高 | 中 | 中〜大規模、エラー種別の判別が必要 |
| Result 型 (成功/失敗の判別付きユニオン) | 高 | 高 | 高 | 関数型スタイル、エラーを戻り値で扱いたい場合 |
向いているケース・向かないケース
型アサーション (as T) が向いているケース:
- 自社で管理している API との通信
- スキーマが単純で変更頻度が低い
- プロトタイプや検証段階のコード
型ガード関数が向いているケース:
- サードパーティ API との通信
- ユーザー入力や外部ファイルの処理
- 型の不整合がクリティカルな影響を持つ処理
カスタムエラークラスが向いているケース:
- エラーの種類によって処理を分岐したい
- エラーに追加情報(ステータスコード、エラーコードなど)を持たせたい
- ログやモニタリングでエラーを分類したい
まとめ
TypeScript で非同期処理を型安全に書くためには、以下のポイントを押さえることが重要です。
- 戻り値型を明示的に定義する:
async function fn(): Promise<T>の形式で戻り値型を宣言し、型推論に頼りすぎない - 外部データには型ガードを適用する: API レスポンスやファイル読み込みなど、実行時まで内容が不明なデータには型ガード関数で検証を行う
- エラー型を設計する:
catchブロックのunknownに対応するため、カスタムエラークラスとinstanceofチェックを組み合わせる - ジェネリクスで再利用性を高める: フェッチ関数やリトライ処理など、汎用的なユーティリティはジェネリクスを活用して型安全かつ再利用可能に実装する
非同期処理の型定義は、プロジェクトの規模や API の信頼性によって最適解が変わります。本記事で紹介したパターンを参考に、プロジェクトの状況に応じた設計を選択してください。
関連リンク
著書
article2026年1月23日TypeScriptのtypeとinterfaceを比較・検証する 違いと使い分けの判断基準を整理
article2026年1月23日TypeScript Genericsの使用例を早見表でまとめる 記法と頻出パターンを整理
article2026年1月22日TypeScriptの型システムを概要で理解する 基礎から全体像まで完全解説
article2026年1月22日TypeScriptでよく出るエラーをトラブルシュートでまとめる 原因と解決法30選
article2026年1月21日TailwindとTypeScriptとReactのユースケース 型安全なデザインシステムを設計して構築する
article2026年1月20日TypeScriptで関数型プログラミングを設計に取り入れる 純粋関数で堅牢にする手順
articleClaude Code と Codex を並列で動かしたときの動き: 同じファイルでも書き換えは消えない
articleClaude Code の Stop フックで、終わる前にテストを走らせる手順: 失敗が残ったまま終わらない
articleClaude Code と Codex のスキルの置き場所の動き: .claude/skills と .agents/skills は互いに読まない
articleClaude Code の /security-review で変更を点検する手順: origin/HEAD がないと止まる
articleClaude Code と Codex のスキルを入れる前に中身を確かめる手順: security-audit で見る5か所
articleCloudflare の security-audit スキルを Claude Code と Codex で比較: 3つとも見つけ、進め方に差
