T-CREATOR

<div />

TypeScriptで非同期処理を型安全に書く使い方 Promiseとasync awaitの型定義を整理

2026年1月18日
TypeScriptで非同期処理を型安全に書く使い方 Promiseとasync awaitの型定義を整理

TypeScript で非同期処理を書いていて、「戻り値の型が any になってしまう」「catch ブロックでエラーの型が unknown になり扱いに困る」といった経験はありませんか。本記事では、Promise の戻り値設計と例外設計を整理し、型推論を活かした安全な非同期コードの書き方を解説します。初学者から実務者まで、非同期処理の型定義で迷わないための判断基準を提供します。

Promise と async/await の型定義における比較

観点Promise チェーンasync/await
戻り値の型定義Promise<T> を明示関数に Promise<T> を明示、または型推論に任せる
エラーの型.catch() 内は unknowncatch ブロック内は 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' は存在しない
  }
}

つまずきやすい点: errorunknown 型のため、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/awaittry-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.allPromise.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 の信頼性によって最適解が変わります。本記事で紹介したパターンを参考に、プロジェクトの状況に応じた設計を選択してください。

関連リンク

著書

とあるクリエイター

フロントエンドエンジニア Next.js / React / TypeScript / Node.js / Docker / AI Coding

;