Closed22

Promise のメモ

hankei6kmhankei6km

記事にしました。

トピックの方は内容が古いままになっているので、記事の方を見ていただければと。

https://zenn.dev/hankei6km/articles/promise-memo


Promise の挙動をきちんと理解していなかったのでメモ。

なお、ここにメモしていることの大半が以下をきちんと理解していれば迷うことはなかった。

待機状態のプロミスは、何らかの値を持つ履行 (fulfilled) 状態、もしくは何らかの理由 (エラー) を持つ拒否 (rejected) 状態のいずれかに変わります。そのどちらとなっても、then メソッドによって関連付けられたハンドラーが呼び出されます。対応するハンドラーが割り当てられたとき、既にプロミスが履行または拒否状態になっていても、そのハンドラーは呼び出されます。よって、非同期処理とその関連付けられたハンドラーとの競合は発生しません。

とくに以下の部分。

対応するハンドラーが割り当てられたとき、既にプロミスが履行または拒否状態になっていても、そのハンドラーは呼び出されます。よって、非同期処理とその関連付けられたハンドラーとの競合は発生しません。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Promise#解説

hankei6kmhankei6km

コードを動かしている環境

バージョンと設定など
$ node --version
v14.18.2
package.json
{
  "name": "promise-memo",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "type": "module",
  "directories": {
    "example": "examples"
  },
  "scripts": {
    "test": "node --loader ts-node/esm examples/memo/promise_is_pending.ts"
  },
  "author": "",
  "license": "MIT",
  "devDependencies": {
    "@types/node": "^17.0.16",
    "ts-node": "^10.5.0",
    "typescript": "^4.5.5"
  }
}
tsconfig.json
{
  "compilerOptions": {
    "target": "es2020",
    "module": "esnext",
    "moduleResolution": "node",
    "strict": true,
    "skipLibCheck": true,
    "declaration": true,
    "pretty": true,
    "newLine": "lf",
    "outDir": "dist",
    "esModuleInterop": true
  },
  "exclude": ["node_modules", "src/**/*.test.ts"],
  "include": [
    "examples/**/*.ts",
    "src/**/*.ts",
    "test/**/*.spec.ts",
    "src/**/*.tsx"
  ]
}
hankei6kmhankei6km

コールバックは(同期的に)即座に開始される

new Promise(cb) を実行すれば then()await を実行しなくとも指定した cb は開始されている。

cb_call_immediately.ts
;(async () => {
  const wait = (to: number) =>
    new Promise<void>((resolve) => setTimeout(() => resolve(), to))

  const p = new Promise<string>((resolve) => {
    console.log('cb start')
    setTimeout(() => resolve('done'), 1000)
  })
  console.log('step0')
  await wait(2000)
  console.log('step1')
  await p.then((v) => console.log(v))
  console.log('step2')
})()
$ node --loader ts-node/esm examples/memo/cb_call_immediately.ts

cb start
step0
step1
done
step2
hankei6kmhankei6km

then() は新しい Promise(のインスタンス)を返す

ここを理解していると後のことがしっくりくる。

then_return_new_promise.ts
;(async () => {
  const wait = (to: number) =>
    new Promise<void>((resolve) => setTimeout(() => resolve(), to))

  const p = new Promise<string>((resolve) => {
    setTimeout(() => resolve('done'), 1000)
  })

  const t1 = p.then((v) => v)
  const t2 = p.then((v) => v)
  console.log(`t1 === t2 : ${t1 === t2}`)
  console.log(`t1: ${await t1}`)
  console.log(`t2: ${await t2}`)
})()
$ node --loader ts-node/esm examples/memo/then_return_new_promise.ts
t1 === t2 : false
t1: done
t2: done
hankei6kmhankei6km

then() await は同時に複数回利用できる

決定後も利用できるが、決定された値は変更できない。

;(async () => {
  const wait = (to: number) =>
    new Promise<void>((resolve) => setTimeout(() => resolve(), to))

  let r: (value: string) => void = (v) => {
    console.log('called before settled')
  }
  const p = new Promise<string>((resolve) => {
    console.log('cb start')
    setTimeout(() => {
      resolve('done')
      r = resolve
    }, 1000)
  })

  ;(async () => {
    p.then((v) => console.log(`then-1: ${v}`))
  })()
  ;(async () => {
    console.log(`await-1: ${await p}`)
  })()
  ;(async () => {
    p.then((v) => console.log(`then-2: ${v}`))
  })()
  ;(async () => {
    console.log(`await-2: ${await p}`)
  })()

  await p
  console.log('another resolve()')
  r('done-2')
  ;(async () => {
    p.then((v) => console.log(`then-1: ${v}`))
  })()
  ;(async () => {
    console.log(`await-1: ${await p}`)
  })()
  ;(async () => {
    p.then((v) => console.log(`then-2: ${v}`))
  })()
  ;(async () => {
    console.log(`await-2: ${await p}`)
  })()
})()
$ node --loader ts-node/esm examples/memo/then_await_call_multiple.ts
cb start
then-1: done
await-1: done
then-2: done
await-2: done
another resolve()
then-1: done
await-1: done
then-2: done
await-2: done
hankei6kmhankei6km

決定後に再利用しても chain を再実行するわけではない

新しいハンドラーは実行されるが、決定された状態は各インスタンスが保持しているもよう。

chain-not-replay.ts
const p = new Promise((resolve) => setTimeout(() => resolve('p')))
const t1 = p.then((v) => {
  console.log('t1')
  return `${v}-t1`
})
const t2 = t1.then((v) => {
  console.log('t2')
  return `${v}-t2`
})
const t3 = t2.then((v) => {
  console.log('t3')
  return `${v}-t3`
})

console.log(await t3)
console.log(await t3)
const t4 = t3.then((v) => {
  console.log('t4')
  return `${v}-t4`
})
console.log(await t4)
console.log(await t2)
export {}
$ node --loader ts-node/esm examples/memo/chain-not-replay.ts 
t1
t2
t3
p-t1-t2-t3
p-t1-t2-t3
t4
p-t1-t2-t3-t4
p-t1-t2
hankei6kmhankei6km

catch も複数同時に利用できる

基本は then() await と同じ。
catch() のコールバックが実行されるだけでなく、try-catch でも throw される。

catch_multiple.ts
// catch は同時に複数回利用できる(throw される)。
// 決定後も利用できる(throw される)。
;(async () => {
  const wait = (to: number) =>
    new Promise<void>((resolve) => setTimeout(() => resolve(), to))

  const p = new Promise<string>((resolve, reject) => {
    console.log('cb start')
    setTimeout(() => reject('rejected'), 1000)
  })

  console.log('before settled')
  ;(async () => {
    p.catch((v) => console.log(`catch-1: ${v}`))
  })()
  ;(async () => {
    try {
      await p
    } catch (r) {
      console.log(`await-1: ${r}`)
    }
  })()
  ;(async () => {
    p.catch((v) => console.log(`catch-2: ${v}`))
  })()
  ;(async () => {
    try {
      await p
    } catch (r) {
      console.log(`await-2: ${r}`)
    }
  })()

  await p.catch((r) => r)

  console.log('after settled')
  ;(async () => {
    p.catch((v) => console.log(`catch-1: ${v}`))
  })()
  ;(async () => {
    try {
      await p
    } catch (r) {
      console.log(`await-1: ${r}`)
    }
  })()
  ;(async () => {
    p.catch((v) => console.log(`catch-2: ${v}`))
  })()
  ;(async () => {
    try {
      await p
    } catch (r) {
      console.log(`await-2: ${r}`)
    }
  })()
})()
$ node --loader ts-node/esm examples/memo/catch_multiple.ts
cb start
before settled
catch-1: rejected
await-1: rejected
catch-2: rejected
await-2: rejected
after settled
catch-1: rejected
await-1: rejected
catch-2: rejected
await-2: rejected
hankei6kmhankei6km

reject の chatch は各 Promise(のインスタンスの chain) に 1 つ必要

返信に追記あり

await などを行う毎に「新しい Promise が返ってくる」ので、その都度 catch しないといけない。
そのため、new Promise()catch を付けるだけでは reject が漏れるときがある。

chain に 1 つあれば大丈夫だが、catch を付けるまえに await を行うと「新しい Promise が返ってくる = chain が分岐するので」漏れる可能性がある。
サンプルコードは返信に追記。

each_await_need_catch.ts
;(async () => {
  const wait = (to: number) =>
    new Promise<void>((resolve) => setTimeout(() => resolve(), to))

  const p = new Promise<string>((resolve, reject) => {
    console.log('cb start')
    setTimeout(() => reject('rejected'), 1000)
  })

  console.log('before sttled')
  ;(async () => {
    p.catch((v) => console.log(`catch-1: ${v}`))
  })()
  ;(async () => {
    try {
      await p
    } catch (r) {
      console.log(`await-1: ${r}`)
    }
  })()
  ;(async () => {
    p.catch((v) => console.log(`catch-2: ${v}`))
  })()
  ;(async () => {
    try {
      await p
    } catch (r) {
      console.log(`await-2: ${r}`)
    }
  })()

  await p.catch((r) => r)

  console.log('after settled')
  // console.log('then only')
  // p.then((v) => v)
  console.log('await only')
  await p
})()
$ node --loader ts-node/esm examples/memo/each_await_need_catch.ts
cb start
before sttled
catch-1: rejected
await-1: rejected
catch-2: rejected
await-2: rejected
after settled
await only
(node:1774651) UnhandledPromiseRejectionWarning: rejected
(node:1774651) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 1)
(node:1774651) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
hankei6kmhankei6km
each_chain_need_catch.ts
;(async () => {
  const wait = (to: number) =>
    new Promise<void>((resolve) => setTimeout(() => resolve(), to))

  const p = new Promise<string>((resolve, reject) => {
    console.log('cb start')
    setTimeout(() => reject('rejected'), 1000)
  })

  const tc1 = p.then((v) => {}).catch((r) => r)
  const tc2 = p.then((v) => {})
  ;(async () => {
    console.log('tc1: then catch -> await chain')
    console.log(`tc1: ${await tc1}`)
  })()
  ;(async () => {
    console.log('tc2: then -> await chain')
    console.log(`tc2: ${await tc2}`)
  })()
})()
$ node --loader ts-node/esm examples/memo/each_chain_need_catch.ts 
cb start
tc1: then catch -> await chain
tc2: then -> await chain
tc1: rejected
(node:2186001) UnhandledPromiseRejectionWarning: rejected
(node:2186001) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 1)
(node:2186001) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
hankei6kmhankei6km

Promise.race に同一の Promise を何度も渡すことができる

ここまでの挙動を見ていれば普通のことなのだが、「一度 Promise.race を通したらハンドラーは動作しない」と勘違いしていた。

以下は、少しわかりにくいが「Promise.race は配列の先頭から settled を探して見つけたら終了する」を利用したコード。

reuse_promise_in_race.ts
;(async () => {
  const wait = (to: number) =>
    new Promise<void>((resolve) => setTimeout(() => resolve(), to))

  const p = [
    new Promise<string>((resolve) => setTimeout(() => resolve('A'), 4000)),
    new Promise<string>((resolve) => setTimeout(() => resolve('B'), 3000)),
    new Promise<string>((resolve) => setTimeout(() => resolve('C'), 2000)),
    new Promise<string>((resolve) => setTimeout(() => resolve('D'), 1000))
  ]
  console.log(await Promise.race(p))
  await wait(1010)
  console.log(await Promise.race(p))
  await wait(1010)
  console.log(await Promise.race(p))
  await wait(1010)
  console.log(await Promise.race(p))
  await wait(1010)
  console.log(await Promise.race(p))
})()
$ node --loader ts-node/esm examples/memo/reuse_promise_in_race.ts
D
C
B
A
A
hankei6kmhankei6km

Promise.race は配列の先頭から settled を探して見つけたら終了する

上記の補足。
settled になっている Promise を外し忘れるとおかしなことになりそう。

promise_race_select_fulfilled.ts
;(async () => {
  const wait = (to: number) =>
    new Promise<void>((resolve) => setTimeout(() => resolve(), to))

  const p = [
    new Promise<string>((resolve) =>
      setTimeout(() => resolve('rsolved'), 1000)
    ),
    Promise.reject('rejected').catch((r) => r)
  ]
  console.log(await Promise.race(p))
  await wait(1010)
  console.log(await Promise.race(p))
})()
$ node --loader ts-node/esm examples/memo/promise_race_select_fulfilled.ts 
rejected
rsolved
hankei6kmhankei6km

Promise.race で Promise を終了した順番に表示する

Promise の状態(pending かなど)を即座に取得する方法がなさそうだったので少し捻った。

reuse_promise_in_race.ts
;(async () => {
  const wait = (to: number) =>
    new Promise<void>((resolve) => setTimeout(() => resolve(), to))

  const popRace = async (p: Promise<string>[]) => {
    let a = [...p]
    while (a.length > 0) {
      const pa = a.map(
        (t, i) =>
          new Promise<[string, number]>((resolve) =>
            t.then((v) => resolve([v, i]))
          )
      )
      const v = await Promise.race(pa)
      console.log(v[0])
      a.splice(v[1], 1)
    }
  }
  popRace([
    new Promise<string>((resolve) => setTimeout(() => resolve('A'), 300)),
    new Promise<string>((resolve) => setTimeout(() => resolve('B'), 200)),
    new Promise<string>((resolve) => setTimeout(() => resolve('C'), 400)),
    new Promise<string>((resolve) => setTimeout(() => resolve('D'), 100))
  ])
})()
$ node --loader ts-node/esm examples/memo/reuse_promise_in_race.ts
D
B
A
C
hankei6kmhankei6km

async generator の yield は promise を渡すと await する

TypeScript では Promise<T>yield すると AsyncGenerator<Promise<T>, TReturn, TNext>AsyncGenerator<T, TReturn, TNext> にしないとエラーになるのでそういうものぽい。

いちおう for await...of を使わずに next() から返ってくる値を使ってみたが、

  1. Promise が返ってくる
  2. await してみると valueT になっている
async_generator_yeild_await.ts
export {}
const wait = (to: number) =>
  new Promise<void>((resolve) => setTimeout(() => resolve(), to))

const promiseArray: () => Promise<string>[] = () =>
  new Array(5).fill('').map(
    (_v, i) =>
      new Promise<string>((resolve) => {
        setTimeout(() => {
          resolve(`done-${i}`)
        }, 100 * (i + 1))
      })
  )

async function* asyncGen(
  p: Promise<string>[]
): AsyncGenerator<Awaited<string>, void, void> {
  // ): AsyncGenerator<string, void, void> {
  // ): AsyncGenerator<Promise<string>, void, void> { これはエラー
  await wait(100)
  yield p[0]
  await wait(100)
  yield p[1]
  await wait(100)
  yield p[2]
  await wait(100)
  yield p[3]
  await wait(100)
  yield p[4]
  await wait(100)
}

function* syncGen(
  p: Promise<string>[]
): Generator<Promise<string>, void, void> {
  yield p[0]
  yield p[1]
  yield p[2]
  yield p[3]
  yield p[4]
}

;(async () => {
  await (async () => {
    console.log('async generator with for await...of')
    for await (let t of asyncGen(promiseArray())) {
      console.log(`${t}`)
      console.log(`awaited ${await t}`)
    }
  })()

  await (async () => {
    console.log('async generator without for await...of')
    const i = asyncGen(promiseArray())
    const v = i.next()
    console.log(`i.next() = ${v}`)
    let t = await v
    while (!t.done) {
      console.log(`${t.value}`)
      t = await i.next()
    }
  })()

  await (async () => {
    console.log('sync generator')
    for (let t of syncGen(promiseArray())) {
      console.log(`${t}`)
      console.log(`awaited ${await t}`)
    }
  })()

  await (async () => {
    console.log('sync generator with for await...of')
    for await (let t of syncGen(promiseArray())) {
      console.log(`${t}`)
      console.log(`awaited ${await t}`)
    }
  })()
})()
$ node --loader ts-node/esm examples/memo/async_generator_yeild_await.ts
async generator with for await...of                                                                       
done-0                                                                                                    
awaited don
awaited done-1
done-2
awaited done-2
done-3
awaited done-3
done-4
awaited done-4
async generator without for await...of
i.next() = [object Promise]
done-0
done-1
done-2
done-3
done-4
sync generator
[object Promise]
awaited done-0
[object Promise]
awaited done-1
[object Promise]
awaited done-2
[object Promise]
awaited done-3
[object Promise]
awaited done-4
sync generator with for await...of
done-0
awaited done-0
done-1
awaited done-1
done-2
awaited done-2
done-3
awaited done-3
done-4
awaited done-4
hankei6kmhankei6km

async generator は yeild で awaited になるので generator の外に reject は伝播しない

↑は間違い。

async generator は promise を yeild すると awaited になるので generator の利用側では iterator.next() で reject となる

for await...of を使っても、自分で while などでループさせても同じ。

sync generator では素通りするので引用の通り(「非同期のジェネレーター関数の場合は for await...of を」のところが少し引っかかるのだけど).

同期ジェネレーター関数の finally ブロックが常に呼び出されるようにするには、非同期のジェネレーター関数の場合は for await...of を、同期ジェネレーター関数の場合は for...of を使用し、ループの中で生成されたプロミスを明示的に待つようにしてください

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/for-await...of

async_generator_reject.ts
export {}
const wait = (to: number) =>
  new Promise<void>((resolve) => setTimeout(() => resolve(), to))

const promiseArray: () => Promise<string>[] = () =>
  new Array(5).fill('').map(
    (_v, i) =>
      new Promise<string>((resolve) => {
        setTimeout(() => {
          resolve(`done-${i}`)
        }, 100 * (i + 1))
      })
  )

async function* asyncGen(
  p: Promise<string>[]
): AsyncGenerator<Awaited<string>, void, void> {
  try {
    await wait(100)
    yield p[0]
    await wait(100)
    yield p[1]
    await wait(100)
    yield p[2]
    await wait(100)
    yield p[3]
    await wait(100)
    yield p[4]
    await wait(100)
    yield p[5]
    await wait(100)
    console.log('async generator done')
  } finally {
    console.log(`async generator: finally`)
  }
}

function* syncGen(
  p: Promise<string>[]
): Generator<Promise<string>, void, void> {
  try {
    yield p[0]
    yield p[1]
    yield p[2]
    yield p[3]
    yield p[4]
    yield p[5]
    console.log('sync generator done')
  } catch (r) {
    console.log(`sync generator: ${r}`)
  } finally {
    console.log(`sync generator: finally`)
  }
}

;(async () => {
  await (async () => {
    console.log('async generator')
    const p = promiseArray()
    const r = new Promise<string>((resolve, reject) => {
      setTimeout(() => reject('rejected'), 1000)
    })
    r.catch((r) => console.log(`catch ${r}`))
    p.splice(4, 0, r)
    try {
      for await (let t of asyncGen(p)) {
        try {
          console.log(`${t}`)
        } catch (r) {
          console.log(`loop: ${r}`)
          throw r
        }
      }
    } catch (r) {
      console.log(`for await...of: ${r}`)
    }
  })()
  console.log('---')

  await (async () => {
    console.log('async generator without for await...of')
    const p = promiseArray()
    const r = new Promise<string>((resolve, reject) => {
      setTimeout(() => reject('rejected'), 1000)
    })
    r.catch((r) => console.log(`catch ${r}`))
    p.splice(4, 0, r)
    try {
      const i = asyncGen(p)
      let t = await i.next()
      while (!t.done) {
        try {
          console.log(`${t.value}`)
          t = await i.next()
        } catch (r) {
          console.log(`loop: ${r}`)
          throw r
        }
      }
    } catch (r) {
      console.log(`while: ${r}`)
    }
  })()
  console.log('---')

  await (async () => {
    console.log('sync generator with for await...of')
    const p = promiseArray()
    const r = new Promise<string>((resolve, reject) => {
      setTimeout(() => reject('rejected'), 1000)
    })
    r.catch((r) => console.log(`catch ${r}`))
    p.splice(4, 0, r)
    try {
      for await (let t of syncGen(p)) {
        try {
          console.log(`${t}`)
        } catch (r) {
          console.log(`loop: ${r}`)
          throw r
        }
      }
    } catch (r) {
      console.log(`for await...of: ${r}`)
    }
  })()
  console.log('---')

  await (async () => {
    console.log('sync generator with for...of')
    const p = promiseArray()
    const r = new Promise<string>((resolve, reject) => {
      setTimeout(() => reject('rejected'), 1000)
    })
    r.catch((r) => console.log(`catch ${r}`))
    p.splice(4, 0, r)
    try {
      for (let t of syncGen(p)) {
        try {
          console.log(`awaited ${await t}`)
        } catch (r) {
          console.log(`loop: ${r}`)
          throw r
        }
      }
    } catch (r) {
      console.log(`for...of: ${r}`)
    }
  })()
})()
$ node --loader ts-node/esm examples/memo/async_generator_reject.ts
async generator
done-0
done-1
done-2
done-3
catch rejected
async generator: finally
for await...of: rejected
---
async generator without for await...of
done-0
done-1
done-2
done-3
catch rejected
async generator: finally
loop: rejected
while: rejected
---
sync generator with for await...of
done-0
done-1
done-2
done-3
catch rejected
for await...of: rejected
---
sync generator with for...of
awaited done-0
awaited done-1
awaited done-2
awaited done-3
catch rejected
loop: rejected
sync generator: finally
for...of: rejected
hankei6kmhankei6km

同期的ジェネレーターと for...of の挙動は Go で Goroutine と Channel を使うパターンからすると「受信側が Channel をクローズしている」ように見えるのでないかと。

できれば generator 側で catch して retrun(generator を終了)させたくなる。

hankei6kmhankei6km

for await...of などでの chatch は reject が発生する前に chain する必要がある

当然と言われればそうなのだがハマりやすいかと。
以下の例では、2 回目の for await...of では順番が回ってくる前の Promise で reject が発生するので

  1. Unhandled の警告
  2. それでもループは継続しているので順番がきたら catch される

という結果になっている。

for await...of に限らずに他の方法のループや、単純に await で待っているなどの場合でも同じ。

need_chain_catch_reject.ts
;(async () => {
  const wait = (to: number) =>
    new Promise<void>((resolve) => setTimeout(() => resolve(), to))

  const promiseArray: () => Promise<string>[] = () =>
    new Array(5).fill('').map(
      (_v, i) =>
        new Promise<string>((resolve) => {
          setTimeout(() => {
            resolve(`done-${i}`)
          }, 100 * (i + 1))
        })
    )

  await (async () => {
    console.log('timeout = 1000')
    const p = promiseArray()
    p.splice(
      4,
      0,
      new Promise<string>((resolve, reject) => {
        setTimeout(() => reject('rejected'), 1000)
      })
    )
    try {
      for await (let t of p) {
        console.log(`${t}`)
      }
    } catch (r) {
      console.log(`catch ${r}`)
    }
  })()

  await (async () => {
    console.log('timeout = 200')
    const p = promiseArray()
    p.splice(
      4,
      0,
      new Promise<string>((resolve, reject) => {
        setTimeout(() => reject('rejected'), 200)
      })
    )
    try {
      for await (let t of p) {
        console.log(`${t}`)
      }
    } catch (r) {
      console.log(`catch ${r}`)
    }
  })()
})()
$ node --loader ts-node/esm examples/memo/need_chain_catch_reject.ts
timeout = 1000
done-0
done-1
done-2
done-3
catch rejected
timeout = 200
done-0
done-1
(node:2165115) UnhandledPromiseRejectionWarning: rejected
(node:2165115) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 1)
(node:2165115) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
done-2
done-3
catch rejected
(node:2165115) PromiseRejectionHandledWarning: Promise rejection was handled asynchronously (rejection id: 1)
hankei6kmhankei6km

ループなどで順番待ちになる Promise の catch

上記の対応的なもの。

catch を chain した Promise を渡すと catch からの return が伝播してしまう.
以下の例だと any で無理やりわたしているが、これは undefined が yield される。
(Promise.reject を return すると Unhandled になる)

別の chain で catch することで for await...of の前で catch できる。
ただし、ループ側で await などが行われると reject 状態になるので(chain が異なるため)、
ループ側でも catch は必要。

async_generator_catch.ts
;(async () => {
  const wait = (to: number) =>
    new Promise<void>((resolve) => setTimeout(() => resolve(), to))

  const promiseArray: () => Promise<string>[] = () =>
    new Array(5).fill('').map(
      (_v, i) =>
        new Promise<string>((resolve) => {
          setTimeout(() => {
            resolve(`done-${i}`)
          }, 100 * (i + 1))
        })
    )

  await (async () => {
    console.log('catch in top of chain')
    const p = promiseArray()
    const r = new Promise<string>((resolve, reject) => {
      setTimeout(() => reject('rejected'), 200)
    })
    const c = r.catch((r) => {
      console.log(`catch ${r}`)
    })
    p.splice(4, 0, c as any)
    try {
      for await (let t of p) {
        console.log(`${t}`)
      }
    } catch (r) {
      console.log(`for await...of ${r}`)
    }
  })()
  console.log('---')

  await (async () => {
    console.log('catch in another chain')
    const p = promiseArray()
    const r = new Promise<string>((resolve, reject) => {
      setTimeout(() => reject('rejected'), 200)
    })
    r.catch((r) => {
      console.log(`catch ${r}`)
    })
    p.splice(4, 0, r)
    try {
      for await (let t of p) {
        console.log(`${t}`)
      }
    } catch (r) {
      console.log(`for await...of ${r}`)
    }
  })()
  console.log('---')
})()
$ node --loader ts-node/esm examples/memo/async_generator_catch.ts 
catch in top of chain
done-0
done-1
catch rejected
done-2
done-3
undefined
done-4
---
catch in another chain
done-0
done-1
catch rejected
done-2
done-3
for await...of rejected
hankei6kmhankei6km

promise の状態を同期的に取得する方法(API)は無いが、人間が目視で確認はできる

console.log() で Promise をオブジェクトとして出力すると状態が表示される.
またデバッガーでも表示される.
デバッガーで Promise を表示しているスクリーンショット
状態も表示される

ただし toString() などではできない. これができると古典的な isArray のようなことが出来そうだったのだが.
https://stackoverflow.com/questions/30564053/how-can-i-synchronously-determine-a-javascript-promises-state

promise_is_pending.ts
;(async () => {
  let passResolve: (value: string) => void = (v) => {}
  const p1 = new Promise<string>((resolve) => {
    passResolve = resolve
  })
  console.log(p1)
  console.log(p1.toString())
  passResolve('done')
  await p1
  console.log(p1)

  let passReject: (value: string) => void = (v) => {}
  const p2 = new Promise<string>((resolve, reject) => {
    passReject = reject
  }).catch((r) => r)
  console.log(p2)
  console.log(p2.toString())
  passReject('rejected')
  await p2
  console.log(p2)
})()
$ node --loader ts-node/esm examples/memo/promise_is_pending.ts
Promise { <pending> }
[object Promise]
Promise { 'done' }
Promise { <pending> }
[object Promise]
Promise { 'rejected' }
hankei6kmhankei6km

async generator 経由で Promise (awaitedでない)を渡す

渡すことは他の型か関数でくるむことで対応できる。
(関数を使う方が好みだが、副作用がありそうな気もしないでもない)

ただし、for await...of の await で待たないので reject は sync generator のときと同じような挙動になる。
いわゆる「finally に到達しない問題」はループ内での await で回避できるが、この挙動は「async generator 側で catch できない(generator 側で自身を終了できない)」問題がある。
対応としては async generator 側で catch しておくことだが、これはループ側に reject が伝播してしまう(その方が都合がよさそうだが)。

pass_promise_via_async_generator.ts
export {}
const wait = (to: number) =>
  new Promise<void>((resolve) => setTimeout(() => resolve(), to))

const promiseArray: () => Promise<string>[] = () =>
  new Array(5).fill('').map(
    (_v, i) =>
      new Promise<string>((resolve) => {
        setTimeout(() => {
          resolve(`done-${i}`)
        }, 100 * (i + 1))
      })
  )

async function* asyncGenArray(
  p: Promise<string>[]
): AsyncGenerator<[Promise<string>], void, void> {
  try {
    for (let t of p) {
      await wait(100)
      yield [t]
    }
    console.log('async generator(array) done')
  } catch (r) {
    console.log(`async generator(array): ${r}`)
  } finally {
    console.log(`async generator(array): finally`)
  }
}

async function* asyncGenFunc(
  p: Promise<string>[]
): AsyncGenerator<() => Promise<string>, void, void> {
  try {
    for (let t of p) {
      await wait(100)
      yield () => t
    }
    console.log('async generator(func) done')
  } catch (r) {
    console.log(`async generator(func): ${r}`)
  } finally {
    console.log(`async generator(func): finally`)
  }
}

async function* asyncGenCatch(
  p: Promise<string>[]
): AsyncGenerator<() => Promise<string>, void, void> {
  try {
    let err: any
    for (let t of p) {
      const c = t.catch((r) => {
        console.log(`async generatro(catch) loop: ${r}`)
        err = r
        return Promise.reject(r)
      })
      if (err) {
        break
      }
      await wait(100)
      yield () => c
    }
    console.log('async generator(catch) done')
  } catch (r) {
    console.log(`async generator(catch): ${r}`)
  } finally {
    console.log(`async generator(catch): finally`)
  }
}

;(async () => {
  await (async () => {
    console.log('wrap appray')
    try {
      for await (let t of asyncGenArray(promiseArray())) {
        console.log(`${t[0]}`)
        console.log(`awaited ${await t[0]}`)
      }
    } catch (r) {
      console.log(`for await...of: ${r}`)
    }
  })()
  console.log('---')

  await (async () => {
    console.log('wrap func')
    try {
      for await (let t of asyncGenFunc(promiseArray())) {
        const p = t()
        console.log(`${p}`)
        console.log(`awaited ${await p}`)
      }
    } catch (r) {
      console.log(`for await...of: ${r}`)
    }
  })()
  console.log('---')

  await (async () => {
    console.log('reject')
    const a = promiseArray()
    a.splice(
      4,
      0,
      new Promise<string>((resolve, reject) => {
        setTimeout(() => reject('rejected'), 1000)
      })
    )
    try {
      for await (let t of asyncGenFunc(a)) {
        const p = t()
        console.log(`${p}`)
        console.log(`awaited ${await p}`)
      }
    } catch (r) {
      console.log(`for await...of: ${r}`)
    }
  })()
  console.log('---')

  await (async () => {
    console.log('reject catch')
    const a = promiseArray()
    a.splice(
      4,
      0,
      new Promise<string>((resolve, reject) => {
        setTimeout(() => reject('rejected'), 1000)
      })
    )
    try {
      for await (let t of asyncGenCatch(a)) {
        const p = t()
        console.log(`${p}`)
        await p
          .then((v) => {
            console.log(`then ${v}`)
          })
          .catch((r) => {
            console.log(`catch: ${r}`)
          })
      }
    } catch (r) {
      console.log(`for await...of: ${r}`)
    }
  })()
})()
$ node --loader ts-node/esm examples/memo/pass_promise_via_async_generator.ts
wrap appray
[object Promise]
awaited done-0
[object Promise]
awaited done-1
[object Promise]
awaited done-2
[object Promise]
awaited done-3
[object Promise]
awaited done-4
async generator(array) done
async generator(array): finally
---
wrap func
[object Promise]
awaited done-0
[object Promise]
awaited done-1
[object Promise]
awaited done-2
[object Promise]
awaited done-3
[object Promise]
awaited done-4
async generator(func) done
async generator(func): finally
---
reject
[object Promise]
awaited done-0
[object Promise]
awaited done-1
[object Promise]
awaited done-2
[object Promise]
awaited done-3
[object Promise]
async generator(func): finally
for await...of: rejected
---
reject catch
[object Promise]
then done-0
[object Promise]
then done-1
[object Promise]
then done-2
[object Promise]
then done-3
[object Promise]
async generatro(catch) loop: rejected
catch: rejected
async generator(catch) done
async generator(catch): finally
hankei6kmhankei6km

コードブロック的な中で設定した Chain はブロックを抜けても継続して機能する

これも当然と言われればおっしゃる通り。

ループの中でキャンセルのトリガー的に使おうとすると Promise が settled になった時点で
ループ内で設定された Chain がすべて実行される。

  • 処理がおわったと思っていたところで不用意にキャンセル処理が実行される
  • ループの回数が多ければリソースを圧迫する

ループ内の処理をキャンセルさせる場合は Abort Controller と組み合わせるのが無難か?

  • AbortController は明示的にハンドラーを開放できる
  • Promise のように addEventListener を設定しても既に実行されている abort には反応しない
    • signal.aborted で判別させる

以下のサンプルで最初の setTimeout の値を変更すると挙動の違いがわかる。

chain-is-persistent.ts
const cancel = new Promise<void>((resolve) =>
  setTimeout(() => {
    console.log('--- first promise is done')
    resolve()
  }, 3000)
)

console.log('==== start(bare promise)')
for (let idx = 0; idx < 10; idx++) {
  const p = ((c: Promise<void>, i: number) =>
    new Promise<string>((resolve) => {
      let id: any = setTimeout(() => {
        id = undefined
        resolve(`${i} is done`)
      }, 100)
      c.then(() => {
        console.log(`cancel ${i}`)
        if (id) {
          id = undefined
          clearTimeout(id)
        }
        resolve(`abort ${i}`)
      })
    }))(cancel, idx)
  await p.then((v) => {
    console.log(v)
  })
}
console.log('done(bare promise)')

console.log('==== start(AbortController)')
const ac = new AbortController()
cancel.then(() => {
  console.log('call ac.abort()')
  ac.abort()
})
for (let idx = 0; idx < 10; idx++) {
  const p = ((c: Promise<void>, i: number) =>
    new Promise<string>((resolve) => {
      if (!ac.signal.aborted) {
        const handleAbort = () => {
          if (id) {
            id = undefined
            clearTimeout(id)
          }
          resolve(`cancel ${i}`)
        }
        let id: any = setTimeout(() => {
          id = undefined
          ac.signal.removeEventListener('abort', handleAbort)
          resolve(`${i} is done`)
        }, 100)
        ac.signal.addEventListener('abort', handleAbort, { once: true })
      } else {
        resolve(`aborted ${i}`)
      }
    }))(cancel, idx)
  await p.then((v) => {
    console.log(v)
  })
}
console.log('done(AbortController)')

export {}
$ node --loader ts-node/esm examples/memo/chain-is-persistent.ts
==== start(bare promise)
0 is done
1 is done
2 is done
3 is done
4 is done
5 is done
6 is done
7 is done
8 is done
9 is done
done(bare promise)
==== start(AbortController)
0 is done
1 is done
2 is done
3 is done
4 is done
5 is done
6 is done
7 is done
8 is done
9 is done
done(AbortController)
--- first promise is done
cancel 0
cancel 1
cancel 2
cancel 3
cancel 4
cancel 5
cancel 6
cancel 7
cancel 8
cancel 9
call ac.abort()
hankei6kmhankei6km

catch とは

ここまであやふあになっていたので。

Unhandled にさせない正常終了せる、という意味では最終的に reject を return しない(または throw させない)。
なお、reject 以外を return すると履行状態で値が設定される。

behavior-catch.ts
const p = new Promise((resolve, reject) =>
  setTimeout(() => reject('rejected'), 100)
)

console.log('=== return undedined')
try {
  const v = p.catch((r) => {
    console.log(`catch inner ${r}`)
  })
  console.log(`resolved ${await v}`)
} catch (r) {
  console.log(`catch outer ${r}`)
}
console.log('')

console.log('=== return reject')
try {
  const v = p.catch((r) => {
    console.log(`catch inner ${r}`)
    return Promise.reject(r)
  })
  console.log(`resolved ${await v}`)
} catch (r) {
  console.log(`catch outer ${r}`)
}
console.log('')

console.log('=== return undedined(bare)')
const v1 = p.catch((r) => {
  console.log(`catch inner ${r}`)
})
console.log(`resolved ${await v1}`)
console.log('')

console.log('=== return reject(bare)')
const v2 = p.catch((r) => {
  console.log(`catch inner ${r}`)
  return Promise.reject(r)
})
console.log(`resolved ${await v2}`)
console.log('')

export {}
$ node --loader ts-node/esm examples/memo/behavior-catch.ts 
=== return undedined
catch inner rejected
resolved undefined

=== return reject
catch inner rejected
catch outer rejected

=== return undedined(bare)
catch inner rejected
resolved undefined

=== return reject(bare)
catch inner rejected
catch inner rejected
'rejected'

$ echo "${?}"
1
このスクラップは2022/02/28にクローズされました