1. ホーム
  2. TypeScript

【TypeScript】async 関数と Promise 型の使い方|非同期処理に型を付ける方法

Share

JavaScript で書けていた非同期処理を TypeScript に移すと、「戻り値の型に User と書いたらエラーになった」「await したのに型が any のまま」「catch した変数の message が読めない」といったところでつまずきがちです。原因のほとんどは、async 関数と Promise<T> の関係、そして値を取り出したときに型がどう推論されるかを押さえていないことにあります。この記事では、非同期処理に型を付ける具体的な書き方を、fetch を使った実践例や Promise.all、エラー処理まで含めて解説します。

async 関数の戻り値は必ず Promise になる

まず前提として、async を付けた関数の戻り値は必ず Promise でくるまれます。関数の中で return 1 と書いても、呼び出し側が受け取るのは number ではなく Promise<number> です。TypeScript もこのルールどおりに型を推論します。

basic.ts
// 戻り値の型注釈を書かなくても Promise<number> と推論される
async function getCount() {
  return 1;
}

const result = getCount();
// result: Promise<number>

// Promise を return しても二重にはならない(Promise<Promise<number>> にはならない)
async function getCount2() {
  return Promise.resolve(1);
}
// getCount2(): Promise<number>

ポイントは、Promisereturn しても Promise<Promise<number>> のように入れ子にならないことです。Promise は解決値が Promise だった場合それを平坦化する仕様で、型のうえでも同じ扱いになります。代表的な書き方と推論結果を並べると次のとおりです。

関数の中身推論される戻り値の型
return 1;Promise<number>
return Promise.resolve(1);Promise<number>
何も return しないPromise<void>
throw new Error('...'); だけPromise<never>
条件によって stringnull を返すPromise<string | null>

戻り値の型注釈は Promise<T> で書く

推論に任せても動きますが、関数の戻り値は明示しておくと「意図しない値を返してしまった」ことをその場で検出できます。async 関数に型注釈を書くときは、中で return する値の型ではなく、それを Promise でくるんだ型を書きます。

user.ts
type User = {
  id: number;
  name: string;
  email: string;
};

// 正しい書き方:Promise<User>
async function findUser(id: number): Promise<User> {
  const user = await loadFromDb(id);
  return user; // User を返せば OK
}

// 値を返さない場合は Promise<void>
async function saveUser(user: User): Promise<void> {
  await writeToDb(user);
}

// エラー:The return type of an async function must be the global Promise type.
// async function findUserNg(id: number): User {
//   return await loadFromDb(id);
// }

最後のコメント部分のように async function findUser(id: number): User と書くと、「async 関数の戻り値の型は Promise でなければならない」というエラーになります。関数の中で await して User を取り出しているので User を返している気分になりますが、呼び出し側が受け取るのはあくまで Promise<User> です。async を付けた時点で戻り値は1段くるまれる、と覚えておくと迷いません。

await した値の型はどう推論されるか

awaitPromise の中身を取り出す演算子で、型のうえでも Promise<T> から T を取り出します。Promise<User>await すれば UserPromise<string[]>await すれば string[] です。特別な記述は要らず、変数に型注釈を書かなくても正しく推論されます。

await-types.ts
async function main() {
  const user = await findUser(1);
  // user: User(Promise<User> から中身が取り出される)
  console.log(user.name);

  // Promise ではない値を await しても型は変わらない
  const n = await 42; // n: number

  // Promise<T> | T のような型も、await すれば T にそろう
  const value = await maybeAsync(); // value: string
}

declare function maybeAsync(): Promise<string> | string;

Promise でない値を await しても型はそのままなので、「Promise かもしれないし、そうでないかもしれない」という戻り値も await するだけで型がひとつにそろいます。なお、この「くるみを1枚はがす」処理を型のレベルだけで行いたいときは Awaited<T> というユーティリティ型が用意されています。関数の戻り値から解決後の型を取り出したいときなどに使えます。

Promise を返す関数の型を書く

コールバックやプロパティとして「非同期の関数」を受け取りたい場面では、関数の型を書くことになります。このとき注意したいのは、型のうえに async は現れないということです。async は「この関数は Promise を返すように実装されている」という実装側の書き方であって、呼び出す側から見た型は「Promise を返す関数」でしかありません。

fetcher.ts
// 関数型として書く(async は書かない)
type UserFetcher = (id: number) => Promise<User>;

// async 関数はこの型に代入できる
const fetchUser: UserFetcher = async (id) => {
  const res = await fetch(`/api/users/${id}`);
  return (await res.json()) as User;
};

// async でなくても、Promise を返せば同じ型として扱える
const fetchUserFromCache: UserFetcher = (id) => Promise.resolve(cache[id]);

// 引数として非同期関数を受け取る
async function retry<T>(task: () => Promise<T>, times: number): Promise<T> {
  let lastError: unknown;
  for (let i = 0; i < times; i++) {
    try {
      return await task();
    } catch (error) {
      lastError = error;
    }
  }
  throw lastError;
}

declare const cache: Record<number, User>;

インターフェースのメソッドとして書く場合も同じで、findUser(id: number): Promise<User>; のように戻り値を Promise にします。実装側が async かどうかは呼び出し側には関係がなく、上の fetchUserFromCache のように Promise.resolve() を返すだけの同期的な実装も同じ型として渡せます。

fetch のレスポンスに型を付ける

実務でいちばん型が抜け落ちやすいのが fetch です。await fetch(...) の戻り値 Response にはきちんと型が付いていますが、response.json() の型定義は Promise<any> です。サーバーから何が返ってくるかは TypeScript には分からないので当然なのですが、any のまま受け取ると以降のコードで型チェックが一切効かなくなります。

api.ts
async function getUserNg(id: number) {
  const response = await fetch(`/api/users/${id}`);
  const data = await response.json();
  // data: any → 存在しないプロパティを書いてもエラーにならない
  console.log(data.nmae); // タイプミスに気づけない
  return data;
}

// 受け取る変数に型注釈を付ける
async function getUser(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) {
    throw new Error(`ユーザーの取得に失敗しました: ${response.status}`);
  }
  const data: User = await response.json();
  return data;
}

受け取る変数に : User と書くだけで、そこから先のコードは User として扱われます。汎用の関数にしたい場合は、型引数を取るラッパーを1つ用意しておくと使い回せます。

http.ts
async function getJson<T>(url: string): Promise<T> {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }
  return (await response.json()) as T;
}

// 呼び出し側で型を指定する
const user = await getJson<User>('/api/users/1');
const users = await getJson<User[]>('/api/users');

型注釈は「そう来るはず」という宣言にすぎない

ここで押さえておきたいのは、上の書き方はどれも実行時には何も検査していないということです。型注釈や as T はコンパイル時の情報でしかないので、API が想定と違う JSON を返しても TypeScript は止めてくれません。user.name が実際には undefined で、画面表示のところで初めて壊れる、ということが起こります。

外部 API のように形が保証できない相手には、受け取った値をいったん unknown にして、型ガード(型述語を返す関数)で検査してから使うのが安全です。

guard.ts
// 引数が User かどうかを実行時に判定する型ガード
function isUser(value: unknown): value is User {
  if (typeof value !== 'object' || value === null) return false;
  const v = value as Record<string, unknown>;
  return (
    typeof v.id === 'number' &&
    typeof v.name === 'string' &&
    typeof v.email === 'string'
  );
}

async function getUserSafely(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  const data: unknown = await response.json(); // any ではなく unknown で受ける

  if (!isUser(data)) {
    throw new Error('APIのレスポンス形式が想定と違います');
  }

  // ここから先の data は User として扱える
  return data;
}

value is User という戻り値の書き方が型述語で、この関数が true を返した分岐では引数が User に絞り込まれます。検査項目が多くて手書きがつらい場合は、zod や valibot のようなスキーマ検証ライブラリを使うと、スキーマ定義から型と実行時チェックの両方を作れます。

Promise.all はタプルとして推論される

複数の非同期処理をまとめて待つ Promise.all() は、配列リテラルを直接渡すとそれぞれの位置の型を保ったタプルとして推論されます。戻り値を分割代入すれば、変数ごとに違う型がそのまま付きます。

parallel.ts
declare function fetchUser(id: number): Promise<User>;
declare function fetchPosts(userId: number): Promise<Post[]>;
declare function fetchCount(): Promise<number>;

async function loadPage(id: number) {
  // Promise<[User, Post[], number]> と推論される
  const [user, posts, count] = await Promise.all([
    fetchUser(id),
    fetchPosts(id),
    fetchCount(),
  ]);

  // user: User / posts: Post[] / count: number
  console.log(user.name, posts.length, count);
}

// 同じ型の配列を渡した場合は配列型になる
async function loadUsers(ids: number[]) {
  const users = await Promise.all(ids.map((id) => fetchUser(id)));
  // users: User[]
  return users;
}

タプルとして推論されるのは、配列リテラルをその場で渡したときです。いったん const tasks = [fetchUser(id), fetchCount()] のように変数に入れると (Promise<User> | Promise<number>)[] というユニオンの配列になり、結果も (User | number)[] になって位置ごとの型が失われます。分割代入で使うなら、Promise.all() の引数に直接書くか、変数側に as const を付けてください。

なお Promise.allSettled() の結果は PromiseSettledResult<T> という判別可能なユニオンで、result.status === 'fulfilled' で絞り込むと result.value'rejected' なら result.reason が参照できるようになります。status を見ずにいきなり value を読もうとするとエラーになるのは、この型のおかげです。

catch した値は unknown 型になる

非同期処理のエラー処理でつまずきやすいのが、try...catch で受け取った変数の型です。TypeScript 4.4 以降、strict(正確には useUnknownInCatchVariables)が有効な設定では、catch 節の変数は unknown 型になります。JavaScript では throw できる値に制限がなく、文字列でもオブジェクトでも投げられるため、Error だと決めつけられないからです。

error.ts
async function load(id: number) {
  try {
    return await getUser(id);
  } catch (error) {
    // error: unknown
    // console.log(error.message); // エラー: 'error' is of type 'unknown'

    if (error instanceof Error) {
      // このブロック内では error: Error
      console.error(error.message);
    } else {
      console.error('不明なエラー', error);
    }
    return null;
  }
}

instanceof Error で絞り込めば、そのブロックの中では Error として messagestack を読めます。独自のエラークラスを定義しているなら error instanceof ApiError で分岐すればよく、判定の順番は「具体的なクラス → Error → それ以外」の順にします。

ひとつ紛らわしいのが、promise.catch((error) => ...) というメソッド版のコールバックです。こちらは型定義上の引数が any なので、unknown にはならず、そのまま error.message と書けてしまいます。安全側に寄せたいなら .catch((error: unknown) => ...) と自分で注釈しておくとよいでしょう。

書き方受け取る値の型
try { } catch (error) { }unknownuseUnknownInCatchVariables 有効時)
try { } catch (error: any) { }any(明示すれば従来どおり)
promise.catch((error) => ...)any
promise.catch((error: unknown) => ...)unknown

型が合わないと言われたときに見直すところ

Promise<User> に name プロパティがないと言われる

「プロパティ ‘name’ は型 ‘Promise<User>’ に存在しません」というエラーは、ほぼ確実に await の書き忘れです。const user = getUser(1) のように受け取ると、変数の中身は User ではなく Promise<User> のままなので、プロパティを読もうとした時点でエラーになります。const user = await getUser(1) と直せば解決します。

やっかいなのは、if (getUser(1)) のような条件判定や、JSON.stringify() に渡すケースのようにエラーにならない書き方です。Promise オブジェクトは常に真値なので条件は必ず成立してしまいます。この種の await 忘れは型だけでは防ぎきれないので、TypeScript ESLint の no-floating-promises(結果を扱っていない Promise を警告)や no-misused-promises(Promise を条件式などに使うのを警告)を有効にしておくと検出できます。

await が Top-level で使えないと言われる

awaitasync 関数の中でしか使えないのが基本です。ファイルの一番外側で await を書く(トップレベル await)には、そのファイルがモジュールであり、かつ tsconfig.jsonmodulees2022 / esnext など、targetes2017 以上である必要があります。設定を変えられない場面では、async function main() { ... } でくるんで呼び出すのが確実です。

Promise<User> を Promise<User | null> に代入できないと言われる

逆に「Promise<User | null>Promise<User> に代入できない」と言われた場合は、関数のどこかに nullundefined を返す経路が残っています。if で早期 return したときに値を返し忘れていないか、find() のように undefined を返しうるメソッドの結果をそのまま返していないかを確認してください。見つからないときに null を返す仕様なら、戻り値の型のほうを Promise<User | null> に直すのが正しい対処です。

まとめ

async 関数の戻り値は必ず Promise でくるまれるので、型注釈も Promise<User> のように書きます。await すれば Promise<T> から T が取り出され、変数の型は自動的に推論されます。非同期関数を値として受け渡すときは、型のうえに async は現れず (id: number) => Promise<User> と書く点も押さえておきましょう。実践で注意が必要なのは fetch のレスポンスで、response.json()Promise<any> なので、変数に型注釈を付けるか、unknown で受けて型ガードで検査してから使います。Promise.all() は配列リテラルを直接渡すとタプルとして推論され、分割代入でそれぞれの型が保たれます。エラーは unknowncatch されるため、instanceof Error で絞り込んでから message を読んでください。型が合わないときはまず await の付け忘れを疑う、これだけでも多くのエラーは解決します。

参考ページ