thumbnail

TypeScriptで学ぶ!asyncとawait、Promiseの分かりやすい解説

モダンなJavaScriptの開発ではPromiseやasync、awaitの利用が欠かせません。今回はこれらの意味について極限まで簡単にして説明します。10分程度で理解できる内容です。この内容を理解すれば、今後出くわすであろう、応用的なコードについても理解できるはずです。

Proimse/ async / awaitの基本

意味と使いどころ

Promise、await、asyncの簡単な意味は次の通りです。

それぞれの意味

  1. Promise: 非同期処理を簡単に扱うためのもの
  2. await: 非同期処理が終わるまで待つもの
  3. async: その関数が非同期処理か、普通の関数か区別するためのも

Promise/ async/ awaitを使えば、次のような処理が簡単に読みやすく書けます。

以下の処理が簡単に!

  1. 非同期処理を簡単に記述 (コールバック地獄から解放, thenより簡単)
  2. 複数の非同期処理を同時(並列)に走らせる(配列で指定するだけ)

この記事では最も基本である①の処理について解説していきます。

②の『複数の非同期処理を同時(並列)』に走らせる方法は次の記事を参考にしてください。

使い方の概要

Promise/ await/ asyncは次の3つのステップに分けると理解しやすいです。

詳しい意味は順を追って説明しますので、今はわからなくて構いません。

3つのステップ

  1. 非同期処理をPromise()で囲み、newする → new Promise(非同期処理)
  2. new Promise(非同期処理)の左にawaitを付ける → await new Promise(非同期処理)
  3. awaitを使った一連の処理を関数にしてasyncをつける

今回は非同期処理として、setTimeout()関数を例にして進めていきます。
setTimeoutは、コールバックで登録した関数を、指定した秒数(ミリ秒)後に遅延実行してくれる関数です。

// 3000ミリ秒 = 3秒後に "Hello World!"が表示される
setTimeout(() => console.log("Hello World!"), 3000)

それでは使い方を順に解説していきます。

Proimse/ async / awaitの使い方

①非同期処理をPromiseでラップする

Promiseとコールバックで囲む

まずは非同期処理をPromiseオブジェクトで囲みます。
ただし、そのままラップするのではなく、次のようにPromiseの中でコールバック関数を定義してラップします。

// functionキーワードの例
Promise(function() {
  非同期処理
})

// アロー関数の例
Promise(() => 非同期処理)

つまり「①Promise」と「②コールバック」で2重にラップすることになります。とりあえずはここらへんは決まりとして覚えておいて下さい。

具体例としてsetTimeoutを考えているので、具体的には次のように書けます。

Promise(() => {
  setTimeout(() => console.log("Hello World!"), 3000)
})

また、Promiseはnewをつけないと使えない決まり(インスタンス化しないと使えない)なのでnewも付けます。

new Promise(() => {
  setTimeout(() => console.log("Hello World!"), 3000)
})

コールバックに引数を与える

先ほどはコールバックを引数なしで呼び出しましたが、第一引数を与えて、それを使うことによって「非同期処理が終わったことを非同期処理の外部に伝える」準備ができます。

後で説明しますが、外部とはawaitのことで、「非同期処理が終わったことが伝えらえる」までプログラムが止まって待っててくれるようになります。

第一引数にresolveという名前で引数を与えてみます。(resolveという名前は何でもいいです。)

new Promise((resolve) => {
  setTimeout(() => console.log("Hello World!"), 3000)
})

引数を与えましたがまだ使っていないので、これだけでは非同期処理が終わったことを伝えられません。
それではこの第一引数をどう使えば処理が終わったことを伝えられるのでしょうか。
それは、resolve()として、関数として呼び出すだけです。

new Promise((resolve) => {
  setTimeout(() => {
    console.log("Hello World!")
    resolve()
  }, 3000)
})

これで、3秒後に”Hello World”と表示され、その非同期処理が終わったことを外部(await)に伝えることが出来ます。

また、resolve(“hoge”)のようにresolveに引数を指定することで、「①終わったことを伝える機能」に加えて、「②引数(“hoge”)を非同期処理の外部(await)に渡す」ことも可能となります。実用例では、非同期処理で得たデータを外部に渡したい場合などが該当するでしょう。

Promiseの型について

上のコードはTypeScriptだとエラーが出ます。Promiseの後ろにジェネリックを付けてresolveの引数の型を指定しなければなりません。

new Promise(非同期処理)の返す型は、Promise<T>型のオブジェクトとなります。Tはresolveの引数の型です。上の例ではresolveの引数はないのでvoidを指定します。

new Promise<void>((resolve) => {
  setTimeout(() => {
    console.log("Hello World!")
    resolve()
  }, 3000)
})

例えばresolve(“hoge”)という呼び出しならばPromise<string>型、resolve(3)ならPromise<number>型となります。

②awaitで非同期処理が終わるのを待つ

await句はPromise<T>型のオブジェクトにつけます。

awaitかthenか

①の次の処理方法は、2択あります。

  • ケース1:後ろにthen句を続ける
    ケース2:前にawait句を付ける
//ケース1
new Promise<void>((resolve) => {
  setTimeout(() => {
    console.log("Hello World!")
    resolve()
  }, 3000)
}).then(/* なんかの処理 */)
//ケース2
await new Promise<void>((resolve) => {
  setTimeout(() => {
    console.log("Hello World!")
    resolve()
  }, 3000)
})

今回はこの記事のタイトル通りケース2のawait句を使った方法を説明をします。

then句の使い方やawait句との違いはこちらで紹介しています。

await句には大きく分けて2つの意味があります。

awaitの役割

  1. Promise型の非同期処理が終わるのを待つ
  2. Promise型の非同期処理から値を受け取る

awaitの意味①:Promise型の非同期処理が終わるまで待つ

awaitは、非同期処理が終わるのを待つためのものです。処理がここで一旦止まります。
new Promise(非同期処理) にawaitをつけることで、非同期処理が終わるまでプログラムが待ってくれるようになります。

// 何らかの処理 ....
await new Promise<void>((resolve) => {
  setTimeout(() => resolve(), 5000); //この行で処理が一旦と止まる!!!
});
// ↑の非同期処理が終わったらこの行以降へ進める

非同期処理が終わったことを伝えるにはresolve()を呼び出すのでした。つまり、resolve()が呼ばれるまで、await句が待ってくれる役割を果たします。そしてresolveが呼ばれた後にawait句の次の行へ進めるようになります。

例えば、次のようにawait句が連続している場合、”各行ごとに”、非同期処理が終わるのを待ってから次の行へ進みす。

await new Promise<void>(resolve => setTimeout(() => resolve(), 3000)) //3秒待ってから次の行へ
await new Promise<void>(resolve => setTimeout(() => resolve(), 1000)) //1秒待ってから次の行へ
await new Promise<void>(resolve => setTimeout(() => resolve(), 2000)) //2秒待ってから次の行へ
console.log("hoge")

各行で3秒+1秒+2秒=計6秒待つので、最後の行のconsole.logが表示されるのは6秒後となります。

awaitを付けなかった場合は非同期処理が終わるのを待ってくれないので、プログラムを実行した瞬間にconsole.logが表示されます。

awaitの意味②:Promise型の非同期処理の中から値を受け取る

先ほど少し触れましたが、resolve関数に引数を渡すことで、その引数を外部に渡すことが出来るのでした。以下で図説します。

このようにawait句を変数に代入することで、resolveの引数を、非同期処理外部の引数で受け取ることが可能となります。この時左辺の変数の型は、Promise<T>のジェネリックで指定した型Tになります。

型レベルで疑似コードを書くと、await関連の型は次のようになります。

const hoge: T = await new Promise<T>(非同期処理)

awaitによってPromiseのラップをはがしてTを取り出すイメージです。

number型のコード例も掲載しておきます。

const func = async () => {
  //hoge(number型)にresolveの引数が代入されます
  const hoge = await new Promise<number>(resolve => {
    setTimeout(() => resolve(2023), 5000)
  })
  console.log(hoge)//5秒後に、"2023"と出力
}

Promise<number>がawaitではがされてnumber型になっています。

以上から、awaitは「Promise型のオブジェクトの非同期処理が終わるのを待ってから値を取り出す」ための句といっても過言ではありません。逆に、Promise型のオブジェクトはawaitで待って値を取り出す必要があるということです。

③awaitを使ったブロックを関数にしてasyncで囲む

asyncの使い方

最後にawaitを含む一連のブロックを関数にしてasyncをつけます。
「awaitを使う処理は関数にしてasyncをつける」というだけの決まりです。

functionキーワードによる関数の書き方と、アロー関数での書き方を記載します。

//async 関数
async function func1() {
  await new Promise<void>(resolve => {
    setTimeout(() => resolve(), 5000)
  })
}
//アロー関数を使った場合
const func2 = async () => {
  await new Promise<void>(resolve => {
    setTimeout(() => resolve(), 6000)
  })
}
//実行
func1()
func2()

※以前まではawaitを使った一連の処理は、必ずasync関数にする必要がありましたが、ES2022からはawait句がトップレベルにある場合のみ、asyncがいらなくなりました。

この関数を実行すると、setTimeout()で指定したとおり5秒待つ関数が出来ます。

ここまでのコードでは全然実用的ではないので、5秒待ったらコンソール出力する関数の例も記載します。

const func = async () => {
  await new Promise<void>(resolve => {
    setTimeout(() => resolve(), 5000)
  })
  console.log("5秒待った")
}

asyncの型

asyncを付けた関数の戻り値は、Promise<戻り値の型>型になります。

const a = async () => {} //戻り値はPromise<void>
const b = async () => 2 //戻り値はPromise<number>
//戻り値はPromise<string>
async function c (): Promise<string> {
  return "hoge"
}

Promise型の戻り値ということは、awaitで待って中身を取りだせます。

const result = await b()
console.log(result) //2

このようにasync関数から戻り値を受け取る場合は、await句が必要になります。

実用的なコードに向けて

Promiseを違う関数に分離する

awaitの右辺にPromiseを直書きするするのではなく、別の関数にします。
つまり、Promiseをreturnする関数に分離します。

const func = async () => {
  const hoge = await func2()
  console.log(hoge)//5秒後に、"hoge"と出力
}

//分離
const func2 = () => {
  return new Promise<string>(resolve => {
    setTimeout(() => resolve("hoge"), 5000)
  })
}

この、「await 関数」という形は「await fetch()」「await axios.get()」等よく見かけるでしょう。というのも、fetchもaxiosもこのコードのfunc2と同じく、Promiseを返す関数なのです。

Promiseが失敗した場合を考える

今回のsetTimeout()は確実に成功するので考えていませんでしたが、非同期処理は毎回成功するとは限りません。例えば、外部と通信を行った際にデータを取得できなかった場合、などが挙げられます。

そのような場合に備えて、Promiseの内部の関数で失敗した時用の引数も設定できます。
今までは「処理が成功したこと」を伝えるresolveのみを使っていました。
「失敗したこと」を伝える引数も用意されています。resolveの後にもう1つ失敗した時用の引数を設定するだけです。

const func = async () => {
  return new Promise((resolve, reject) => {
    //成功
    resolve()
    //失敗
    reject()
  })
}

失敗したことを伝えると、エラーが投げられます。
このエラーは呼び出し元のawaitをtry、catch文で囲めば、catchでエラーを補足することが出来ます。

具体的な例を挙げます。indexが2のときに処理が失敗するコードです。

const func = async (index: number) => {
  console.log(`before: ${index}`)
  try {
    await func2(index)
  }
  catch (err) {
    console.log(err) //2は失敗します
  }
}
//分離
const func2 = async (index: number) => {
 return new Promise<void>((resolve, reject) => {
    if (index === 2) reject("2は失敗します")
    setTimeout(() => resolve(), 5000)
  })
}

rejectの引数がcatchで補足されて出力されます。

ここまで来ればasyncやawaitを使ったコードを読めるようになるでしょう。
次の章は今までの応用です。awaitやPromiseが実行されるタイミングについてもう少し細かく見ていきます。

awaitやPromiseの実行タイミング

いくつかコード例をあげて、awaitやPromiseの実行タイミングを見ていきます。

連続で呼び出す

5秒待機する関数を連続して呼ぶとどうなるでしょうか。

const func = async (index: number) => {
  console.log(`before: ${index}`)
  await func2()
  console.log(`after: ${index}`)
}
//分離
const func2 = () => {
  return new Promise<void>(resolve => {
    setTimeout(() => resolve(), 5000)
  })
}
//実行
func(1)
func(2)
func(3)

awaitの前後に引数をコンソール出力する処理を書いています。
結果は次のようになります。

before: 1
before: 2
before: 3
after: 1
after: 2
after: 3

先にbeforeが全て出力されます。そしてそれぞれの呼び出しは5秒待機後、順次afterを出力します。

次のようにawaitで待ってあげることで、各処理で5秒ずつ待機して逐次処理されます。

;(async () => {
  await func(1)
  await func(2)
  await func(3)
})()
before: 1
after: 1
before: 2
after: 2
before: 3
after: 3

なおawait句はasync関数で囲む必要があるので、即時関数にして実行しています。

すぐにPromiseをリターンしない

Promiseをすぐリターンする関数でなく、色々な処理を盛り込みます。
Promiseを実行してからreturnする間にコンソール出力を書いてみます。

const func = async (index: number) => {
  console.log(`before: ${index}`)
  await func2(index)
  console.log(`after: ${index}`)
}
//分離
const func2 = (index: number) => {
  //new したPromiseを一旦変数に代入しておきます
    const promise = new Promise<void>(resolve => {
    setTimeout(() => resolve(), 5000)
  })
  console.log(`${index}の途中`)
  return promise
}
//実行
func(1)
func(2)
func(3)

結果は次のとおりです。

before: 1
1の途中
before: 2
2の途中
before: 3
3の途中
after: 1
after: 2
after: 3

繰り返しになりますが、
awaitの次に進むのは、Promiseがnewされたタイミングではなくresolveが実行されたタイミングです。

次のステップ

①async/ awaitと同じ働きをするthen句について、その違いや使い分け、更に同時利用する方法について解説しています。

②この記事で解説した非同期処理は『順番に1つずつ』実行する形式でしたが、次の記事では『同時に実行』する方法を解説しています。

まとめ

以上asyncとawait、Promiseについて簡単な例を元に説明しました。基本は

  1. ①非同期処理をPromiseでラップする
  2. ②非同期処理が終わるのをawaitで待つ。終わったらPromiseから連絡が来る。
  3. ③awaitを使ったブロックを関数にしてasyncで囲む(そういう決まり)

の3点です。Promiseでresolveするまでawaitする、ということを覚えておきましょう。