TypeScriptの非同期処理とは何か・Promiseの基本的な使い方をまとめてみる【then / catch / finally / async / await】

2022-05-05
Main Image

目次

こんにちは。

今回はTypeScriptの基礎シリーズとして「非同期処理とは何か」についてまとめてみようと思います。

Promiseasyncawait構文の使い方についても触れます。

非同期処理について苦手意識を持たれている方の理解の一助になれば幸いです。

ちなみに非同期処理はTypeScript特有の機能ではなく、JavaScriptでも使える内容となっています。

非同期処理とは

通常のプログラムの実行とは別に実行させておきたい「時間のかかる処理」のこと。

たとえばタイマー処理、データベースとの通信処理、ファイルの読み書き処理などです。

通常TypeScriptではプログラム文が上から下へ処理が実行されていきます。これを非同期処理に対して「同期処理」と言います。非同期処理の部分は結果が返ってくるのを待たずにスルーされて、一旦同期処理が最後まで実行されます。その後、非同期処理の結果が返ってきた時点で、非同期処理の部分が実行されます。

コールバック関数

非同期処理におけるコールバック関数は、「非同期処理が終わったら呼び出される関数」のことです。通常、コールバック関数は「関数に渡される引数としての関数」のことです。非同期処理では、同期処理の後にプログラムの途中に書いた関数が「呼び戻される」ので、コールバックというのだと解釈するとわかりやすいです。

有名なものにはタイマーやイベントが該当します。以下はタイマーで非同期処理が行われ、コールバック関数が実行される例です。

setTimeout(()=>{
    console.log("Called!")
  },5000 // ミリ秒単位
)
console.log("Hello.")

これを実行すると、コンソールには先にHello.が表示され、その5秒後くらいにCalled!と表示されます。これはsetTimeout()が非同期処理になっていて、5000ミリ秒後に実行されるコールバック関数だからです。非同期処理は一旦中身がスルーされて同期処理が先に実行されるので、Hello.が先に表示されます。

Promise

ES6で追加された非同期処理の機能。ES6が何かわからない方はこちらの記事を参照。

Promiseを使う非同期処理の関数はPrimiseオブジェクトを返します。非同期処理の結果が返ってくると、その結果はPromiseオブジェクトからthenメソッドで取り出せるようになります。

つまり、従来のコールバック関数が、「Promiseオブジェクトを返す」「コールバック関数を書いてPromiseから結果を取り出す」という2段階にコードが分割されます。これによりコードの見通しが良くなるだけでなく、Promiseオブジェクトに備わっている様々な便利機能を使うことで非同期処理が実装しやすくなります。

先程のタイマーの例をPromiseを使って書くと、このように実装できます。

const p = new Promise(()=>{
  setTimeout(()=>{
    console.log("called.")
  },5000)
})

p.then()

console.log('Hello.')

Hello.の後にcalled.と表示されることがわかります。

次のように書くことも可能です。

const p = new Promise<string>((resolve)=>{
  setTimeout(() => {
    resolve("Called!")
  },2000)
})

p.then((result) => console.log(result))

console.log('Hello.')

Promise<>に指定する引数(型)は非同期処理の結果の型です。ここではCalled!なのでstringです。`

Promise()の引数はexecutorで、これは関数()=>()です。非同期処理のコールバック関数です。

このexecutorの引数であるresolveに渡される引数が、Promiseの結果です。ここではCalled!という文字列が、Primiseの結果、つまり非同期処理の結果となります。後の.then()メソッドはこの非同期処理の結果を受け取り、引数の関数を実行します。ここでは、()=>console.log()という関数を実行しています。

参考 : MDN web docs | Promise() コンストラクター

参考リンクを見ると分かる通り、executorには2つめの引数である関数rejectがあります。reject関数を呼び出した場合、Promiseは失敗してエラーとしてrejectの引数を返します。

const useResolve = false

const p = new Promise<string>((resolve,reject)=>{
  setTimeout(() => {
    useResolve
    ?resolve("Called!")
    :reject("Rejected!") // useResolveがfalseなのでこちらが呼ばれる
  },2000)
})

p.then((result) => console.log(result))
// Error : Uncaught (in promise) Rejected!

Promise.all

Promise.allメソッドは、引数に複数のPromiseオブジェクトの配列をとり、「すべて成功したら成功」となるPromiseオブジェクトを作ることができます。

const p1 = new Promise<string>((resolve)=>{
  setTimeout(() => {
    resolve("first")},1000)
})
const p2 = new Promise<string>((resolve)=>{
  setTimeout(() => {
    resolve("second")},2000)
})
const p3 = new Promise<string>((resolve)=>{
  setTimeout(() => {
    resolve("third")},3000)
})

const pAll = Promise.all([p1,p2,p3])

pAll.then(([res1,res2,res3])=>{
  console.log(res1)
  console.log(res2)
  console.log(res3)
}) // 1秒ごとではなく、p1,p2,p3のすべての結果が揃った時点で出力される

p1p2p3という3つのPromiseオブジェクトを作ってみました。これら3つに対し.then()を書くと1秒ごとにコンソールに結果が表示されますが、Promise.All()を使うことですべて完了した際に結果を取り出すことが実現できます。ちなみに.then()ではresultsを引数にとる際、resXに分割代入して取り出しています。

逆に、どれか1つでも失敗した時点でPromise.Allの結果は失敗となり、エラーオブジェクトは最初に失敗したPromiseオブジェクトのエラーと同じになります。

Promise.allSettled

all同様Promiseオブジェクトの配列を引数にとり、結果が揃った時点で結果を返します。ただしこちらは、成功オブジェクトは成功の結果を、失敗オブジェクトは失敗の結果を得ることができます。

Promise.race

Promise.race()allのようにPromiseオブジェクトの配列を引数にとり、最初に得た結果を結果とします。

先程allで使ったのと同じPrimiseオブジェクト配列を引数に取ると、結果は当然firstが最初に得られるので、firstが出力されます。

const pRace = Promise.race([p1,p2,p3])

pRace.then((result)=>{
  console.log(`fastest: ${result}`) // first と出力される
})

このようにsecondthirdの結果が揃う前に非同期処理の結果を得ることができます。ただし、あくまでraceの結果が得られるというだけで、裏ではsecondthirdの結果を待っています。すべての非同期処理が中断されるわけではありません。

また、「一番最初に結果を得たオブジェクトの結果となる」とは、成功・失敗を問いません。あるPromiseオブジェクトが最初に失敗した場合は、失敗という結果が返ってきます。

Promise.any

raceが成功・失敗を問わないのに対し、成功のみを対象としたい場合に使います。つまり、「一番最初に成功したPromiseオブジェクトの結果を結果とする」のがPromise.any()です。

失敗はスルーされて他の結果を待ちます。すべて失敗した場合はErrorオブジェクトを返します。

Promiseオブジェクトのメソッド

上記のallなどはPromise.allなどのようにPromise自体につけて呼び出すメソッドでした。クラスでいう静的メソッドです。静的メソッドについてはこちらの記事で解説しています。

一方、.then()Promise.prototypeのメソッド、つまりPromiseオブジェクトを格納した変数(インスタンス)から呼び出すメソッドです。このようなものは他にcatchfinallyがあります。

これらのメソッドはPromiseオブジェクトを返すため、p.finally().catch().try()というようにチェーンして書くことができます。

then

すでに何回も出てきていますが、引数に関数を渡すことで、Primiseの非同期処理が成功したときに呼び出されるコールバック関数を書くことができます。

2つめの引数に失敗時の関数を渡すこともできます。

catch

catch()も関数を引数にとり、この関数はPromiseの結果が失敗したときに呼び出されます。

const p = new Promise<string>((resolve,reject)=>{
  setTimeout(() => {
    reject("Rejected!") // rejectに渡してPromiseを失敗させる
  },2000)
})

p.then((result) => console.log(result)) // then: Errorとして返す
p.catch((result) => console.log(result)) // catch: Rejected!という文字列を返す

Promiseが成功したときは、catchの引数に渡した関数は実行されません。

const p = new Promise<string>((resolve,reject)=>{
  setTimeout(() => {
    resolve("Called!") // resolveに渡してPromiseを成功させる
  },2000)
})

p.then((result) => console.log(result)) // Called!が表示される
p.catch((result) => console.log(result)) // 実行されないので、何も表示しない

catchは非同期処理の失敗をエラーではなく成功の結果として返すことができます。「失敗を成功として返す」とは、以下のようにチェーンすることでエラーの場合の結果を操作できるということです。

const isResolved = true

const p = new Promise<string>((resolve,reject)=>{
  setTimeout(() => {
    isResolved
    ?resolve("Called!") // isResolvedがtrueなら成功させる
    :reject("Rejected!") // isResolvedがfalseなら失敗させる
  },2000)
})

p
.catch(() => "Errorでしたよ")
.then((result) => console.log(result)) // 成功なら「Called!」、失敗なら「Errorでしたよ」

isResolvedtrueのときは成功するのでcatchの中の関数は実行されません。

isResolvedfalseのときは失敗するのでcatchの中の関数が実行され、新しく結果が「Errorでしたよ」のPromiseオブジェクトを返します。そのPromiseオブジェクトに対して.then()の中の関数が呼び出される、という構造になっています。

finally

Promiseの非同期処理が成功でも失敗でも呼び出されるコールバック関数を渡すことができます。

p
.finally(()=> console.log("結果が出ました。"))
.catch(() => "Errorでしたよ")
.then((result) => console.log(result))
// 結果が出ました。
// Errorでしたよ

このようにfinallyをチェーンの中で使うと、渡したコールバック関数はPromiseの結果には影響を与えず実行されます。

非同期関数

Promiseはasyncで宣言する非同期関数やawaitを書く式と組み合わせて使われることが多いです。

async関数

acyncを書いて宣言する関数は、「関数が返す結果」を結果とするPromiseオブジェクトを返します。

const sayYes = async () => "Yes."

sayYes().then((result)=>console.log(result))

console.log('Hello.')

非同期処理は同期処理の後に実行されるので、コンソールに出力される順番は、Helloが先で、次にYesとなります。

await式

async関数の中で宣言することで、「Promiseの結果が出るまで待つ」という処理にすることができます。

タイマーと組み合わせた例を見て確認してみます。

const p2sec = new Promise<string>((resolve)=>{ // 2秒後に結果を得るPromiseオブジェクト
  setTimeout(() => {
    resolve("Called!")
  },2000)
})

const sayYes = async () => {
  console.log("awaitの前です")
  await p2sec // Promiseの結果が出るまで待つ
  console.log("awaitの後です")
  return "Yes."
}

sayYes().then((result)=>console.log(result)) // async関数の結果を出力

console.log('Hello.')

これを実行すると、コンソールにはまず次の順番で出力されます。

awaitの前です
Hello.

async関数の中はawaitが出てくるまで同期処理が行われているため、「awaitの前です」という文字が最初に出力されています。

2秒後、p2secの結果が得られるので以下が出力されます。

awaitの後です
Yes.

このタイミングで「awaitの後です」と出てきました。awaitの結果が得られるまでasync関数の中の処理が中断されているためです。awaitによって「Promiseの結果が出るまで待つ」という処理になっていることが確認できました。

その直後、プログラムはreturn文に到達し、async関数がPromiseの結果を得るのでsayYes.then()の中身がコールバックされ、コンソールにYes.と表示されます。

await式の返り値

また、await式の返り値はawaitで待つPromiseの結果と同じになります。先程のsayYesというasync関数の中を以下のように書き換えてみます。

const sayYes = async () => {
  console.log("awaitの前です")
  const result = await p2sec // resultにawait式を代入
  console.log("awaitの後です")
  console.log(result) // await式の結果、つまりPromiseの結果を出力
  return "Yes."
}

sayYes().then((result)=>console.log(result)) // async関数の結果を出力

この状態で再度実行すると、2秒後に以下の結果がコンソールに出力されます。

awaitの後です
Called!
Yes.

sayYes関数のスコープ内でresultp2secのPromiseの結果です。なので、console.log(result)Called!となります。

次のsayYes.then()が呼んでいるコールバック関数のスコープでは、resultsayYes()の返り値、つまりreturn文に書かれているYes.のことなので、Yes.を出力します。

async関数とawait式のメリット

await式を使うことで、「順序のある非同期処理」を実装しやすくなります。

const pStep = (num:number) => new Promise<string>((resolve)=>{
  setTimeout(() => {
    resolve(`step${num}`)
  },2000)
})

const callStep = async () => {
  const res1 = await pStep(1)
  console.log(res1)
  const res2 = await pStep(2)
  console.log(res2)
  return "finished!"
}

callStep().then((result)=>console.log(result))

pStep()という、引数に数字を入れると2秒後にstep<数字>をPromiseの結果として得る関数を作りました。これをcallStepという非同期関数の中で、awaitをつけて2回呼んでいます。

結果、コンソールには2秒後にstep1、4秒後にstep2と表示され、その直後returnに到達します。その結果、callStep()finished!という結果のPromiseオブジェクトを返し、最後のcallStep().then()の引数に渡されたコールバック関数が呼ばれてコンソール上にfinished!と表示されます。

このように、asyncawaitを使うことで、非同期処理の中でも「ある結果を得てから次の処理をしたい」といった実装が可能です。また、awaitを使うと、同期処理のようにプログラムを上から順番に読むことができるようになるため、コードがとても読みやすくなります。

参考 : MDN web docs | 非同期関数

非同期関数のエラー処理

asyncawaitで非同期処理が同期処理のように書けるということで、エラー処理をしたい場合も同期処理と同様にtry catch構文で書くことができます。

const httpRes = (code:number) => new Promise<string>((resolve,rejected)=>{
  setTimeout(() => {
    code===200
    ?resolve("Success!") // 200なら成功。結果は"Success!"
    :rejected(`${code}`) // 200以外なら失敗。結果はcode。
  },2000)
})

const statusCode = 200

const request = async() => {
  try{
    const res = await httpRes(statusCode)
    console.log(res)
  } catch (err) {
    console.log("Failed : ", err)
  }
}

request()

上記のようにstatusCode200を代入して実行すると、2秒後にコンソールにはSuccess!と表示されます。一方200以外の数字、例えば404を代入するとPromiseの結果は失敗判定され、エラー処理となってコンソールにはFailed : 404という結果が出力されます。

まとめ

というわけで今回はTypeScriptにおける非同期処理の基本として、Promise、async関数、await式についてまとめてみました。

簡単なタイマー処理の例で実装も試してみました。

ご参考になれば幸いです。

プログラミングTypeScript ―スケールするJavaScriptアプリケーション開発

ads【オススメ】未経験からプログラマーへ転職できる【GEEK JOBキャンプ】
▼ Amazonオススメ商品
ディスプレイライト デスクライト BenQ ScreenBar モニター掛け式
スマートLEDフロアライト 間接照明 Alexa/Google Home対応

Author

Penta

都内で働くITエンジニアもどき。好きなものは音楽・健康・貯金・シンプルでミニマルな暮らし。AWSクラウドやデータサイエンスを勉強中。学んだことや体験談をのんびり書いてます。TypeScript / Next.js / React / Python / AWS / インデックス投資 / 高配当株投資 More profile

Location : Tokyo, JPN

Contact : Twitter@penguinchord

Recommended Posts

Copy Right / Penguin Chord, ペンギンコード (penguinchord.com) 2022 / Twitter@penguinchord