◆ 単純に 1 つの Promise のキャンセルなら簡単
  ◆ 実際のキャンセル処理は使う側に任せて その関数を呼び出すだけだし
◆ then を使ったチェーンも考えると難しい

Promise が返ってくるものを使っていて Promise のインスタンスに cancel メソッドがあって

promise.cancel()

ってできたらなぁと思います
fetch はキャンセルに AbortController というのが必要で扱いづらいですが cancel 機能が Promise 自体にあったらと思うんです

そういえば結構前に Proposal でそんな感じのを見たような気がしますが ブラウザに追加されたとか聞きませんし 少し前にもうすぐ追加されそうなのを見たときにはあった気がしません
今使えないものじゃ仕方ないので 自分で簡単なのを作ってみることにしました

CancelablePromise

まず promise.cancel でキャンセルするという使い方は確定です
Promise 自体のメソッドにするので prototype 拡張かクラス継承ですが Promise は内部的に使われてる部分が多く prototype 書き換えは問題が色々出るのでクラス継承にします
実際のキャンセル処理は Promise でやること次第なので 使う側が実装できるようにします

キャンセルされた場合の処理やされたことを知る方法ですが 単純に考えてみるとこういうのがわかりやすそうです

promise.canceled.then(...)

見たままで キャンセルされたらの処理を then に入れます
ただ canceled が新しい別の Promise になるわけで じゃあ元のはずっと resolve も reject もされず pending でいいのでしょうか……
なんかそれは気持ち悪い気がします

reject してしまうなら catch と canceled.then と 2 箇所でキャンセル時の処理を書けるようになってしまいます
シンプルさとわかりやすさ的に 1 箇所だけにしたいと思うので canceled みたいな Promise のプロパティは作らないことにします

元の Promise の状態を変化させるのなら Promise の constructor と同様の引数の関数を実行して resolve や reject をできるようにすれば良さそうです

class CancelablePromise extends Promise {
constructor(fn, oncancel) {
let ok, ng
super((_ok, _ng) => {
ok = _ok
ng = _ng
})
fn(ok, ng)
this._oncancel = oncancel?.bind(this, ok, ng)
}

cancel(msg) {
if (!this._oncancel) {
throw new Error("no cancel function is registered")
}
this._oncancel(msg)
return this
}
}

使用例

const fn = () => {
let tid
return new CancelablePromise((ok, ng) => {
tid = setTimeout(() => {
console.log("do something")
ok("done")
}, 5000)
}, (ok, ng, msg) => {
clearTimeout(tid)
ok("canceled: " + msg)
})
}

const log = (...a) => console.log(new Date().toLocaleTimeString(), ...a)

const p = fn()
log("start")
p.then(() => { log("canceled") })

setTimeout(async () => {
log(await p.cancel("aaa"))
}, 3000)
19:52:14 start
19:52:17 canceled
19:52:17 canceled: aaa

5 秒後に do something と表示して done を受け取るのが通常の処理ですが それが実行されずに canceled と 3 秒後に表示されています

改良する

今回は Promise を作るところでキャンセル時の処理を自由にできるようにしました
キャンセルした場合でも成功として結果を返しています
実際にはキャンセルを成功扱いすることはほぼなさそうですし キャンセルされたことがわからないのも不便です
それを Promise を作る側で全部やらないといけないのは面倒なので もう少し楽にしました

キャンセルすると呼び出される関数ではキャンセル処理のみを行います
キャンセル処理が終わると自動で reject され catch メソッドでは専用のエラーオブジェクトを受け取れます
エラーオブジェクトは cancel プロパティが true で reason プロパティに cancel メソッドに渡した値が入っています

class CancelablePromise extends Promise {
constructor(fn, oncancel) {
let ok, ng
super((_ok, _ng) => {
ok = _ok
ng = _ng
})
fn(ok, ng)

this.cancel = async reason => {
if (!oncancel) {
throw new Error("no cancel function is registered")
}
await oncancel()
const err = new Error("Promise canceled")
err.cancel = true
err.reason = reason
ng(err)
}
}
}

使用例

const fn = () => {
let tid
return new CancelablePromise((ok, ng) => {
tid = setTimeout(() => {
console.log("do something")
ok("done")
}, 5000)
}, () => {
clearTimeout(tid)
})
}

const log = (...a) => console.log(new Date().toLocaleTimeString(), ...a)

const p = fn()
log("start")
p.catch(err => {
console.log(err, err.cancel, err.reason)
})

setTimeout(async () => {
await p.cancel("aaa")
log("cancel success")
}, 3000)
20:16:08 start
Error: Promise canceled
at Promise.CancelablePromise.cancel (<anonymous>:17:16)
at async <anonymous>:46
true "aaa"
20:16:11 cancel success

チェーンできない

単純な使い方ならこれでいいのですが then を使ったチェーンを考えると問題が出てきます
then メソッドの返り値は 内部的に作られる Promise です

const p1 = fn1().then(() => {
return new CancelablePromise(...)
})

のように then の処理で CancelablePromise を返しても then の処理が実際に実行されるのは fn1 が resolve されてからです
p1 に Promise が代入されるまでの処理は同期的に実行されているので p1.cancel が then の中で作った Promise のキャンセル処理と対応していません

現状の CancelablePromise で then メソッドを実行した返り値の Promise は oncancel 処理が空として生成されています
constructor で作った oncancel の引き継ぎなら簡単にできるので p1 に代入直後にキャンセルすれば fn1 の処理をキャンセルすることはできます
しかし それでキャンセルできるのは fn1 のみで fn1 のあとの then での処理に移っていれば cancel メソッドを実行しても終わったものをキャンセルしようとしているだけで then の処理のキャンセルにはなりません
then の仕組みを変えて複雑なことをすればできなくはなさそうですが そこまではやる気が起きないです

そもそもチェーンした場合ってすべてをキャンセルするのが良いのかもはっきりしないところです

const p1 = fn1()
const p2 = p1.then(fn2)
const p3 = p2.then(fn3)
...

のようにひとつひとつ Promise を保持して 指定のものをキャンセルというのだと操作が明確ですが キャンセルしたいのが終わっていた場合に 残り全てにキャンセル処理を送るのは大変です
かと言って 全部キャンセルはそれでいいのかな感もあります

今やりたかったことは特にチェーンを想定してなかったので今のところはこれ以上は対応しないですが Promise といえばチェーンできて当たり前なので 継承よりも専用の別クラスとして作ったほうが良かったかなーと思い始めました

あと AbortController の仕組みを使えば signal を then のチェーンすべてに共有するようにして 各自がキャンセル処理を signal のリスナとして登録すればいいのでこっちのほうが良い方法なのかなぁとか思えてきたり