C# の async/await が分かる人が TypeScript で詰まる5つ — Task と Promise の違い

C# の async/await が分かる人が TypeScript で詰まる5つ — Task と Promise の違い

みなさんこんにちは!ヒロポンです!

C# の async/await はもう手に馴染んでる。Task<T> を返して、await して、try/catch で例外を拾う。業務で何年も書いてきたやつ。

で、ある日「来週からフロント、TypeScript でよろしく」が降ってくる。

開いてみると、async/await がある。お、C# と同じやん??……と思って書くと、これが地味に違う。

TaskPromise、見た目はそっくり。なのに、開始タイミングも例外の消え方も並行の挙動も微妙にズレてる

そのズレを知らずに書くと、本番で「例外が出てるはずなのに何も起きない」みたいな現象でハマります。

この記事は、C# の async/await を知ってる前提で、TypeScript の Promise で詰まる5箇所を「Task ↔ Promise の対応」で翻訳する。C# の感覚を捨てずに、ズレてる行だけ上書きするのが狙いです。

目次

まず対応マップ: C# Task と TypeScript Promise

細かい話に入る前に、全体の対応をこんな感じで1枚に。

C# Task と TypeScript Promise を、開始タイミング・await忘れ・並行待ち・全部待ち・キャンセルの観点で対応させた比較表

この表の各行を、コード付きで1つずつ見ていきます。

詰まり①: Promise は生成した瞬間に走り出す

C# だと、new Task(() => ...) で作った cold な Task は Start() するまで動きません。「作る」と「動かす」が分けられる。

TypeScript の Promise に、その分離はありません。new した瞬間に中身が走ります

const p = new Promise<void>((resolve) => {
  console.log("もう走ってる"); // ← new した時点で出力される
  resolve();
});
console.log("Promise を作った後");

// 出力順: "もう走ってる" → "Promise を作った後"

誰も await していなくても、.then() を付けていなくても、中の処理は始まってる。MDN にも「executor は Promise が構築されると同時に同期的に呼ばれる」と明記されています。

「あとで実行したい」なら、Promise を関数で包んで遅延させます。

// これなら呼ぶまで走らない (cold の代用)
const lazy = () => fetch("/api/data");
// lazy() を呼んだ瞬間に初めて走る

C# の cold Task の感覚で「まだ走ってないはず」と思ってると、ここでズレます。

詰まり②: await を忘れると例外が消える

これがいちばん怖いやつ。

C# なら、Task を返すメソッドを await し忘れると、コンパイラが CS4014 の警告を出してくれます。気づける。

TypeScript は、await を忘れても警告が出ません

しかも、中で投げた例外が同期の try/catch に引っかからずに消えます。これがタチ悪い。

async function risky(): Promise<void> {
  throw new Error("失敗した");
}

function caller() {
  risky(); // ← await を忘れた。例外はここで捕まらない
  console.log("ここは普通に通る");
}
// "失敗した" は Unhandled promise rejection になり、
// 同期の try/catch には一切引っかからない

await を付ければ、いつもの try/catch で拾えます。

async function caller() {
  try {
    await risky(); // await して初めて例外が同期フローに乗る
  } catch (e) {
    console.log("捕まえた:", (e as Error).message);
  }
}

「例外が出てるはずなのに何も起きない」の正体は、だいたいこの await 忘れ。C# の警告に守られてた身からすると、ここは無防備に感じます。

詰まり③: Promise.all は1つこけたら全体がこける

並行実行は、C# の Task.WhenAll に当たるのが Promise.all です。でも失敗時の挙動が違う。

Task.WhenAll は、全部のタスクが終わるのを待ってから、例外をまとめて投げる。

Promise.all は、1つでも reject した瞬間に即 reject(fail-fast)。残りの完了は待ちません。

try {
  await Promise.all([ok(), fail(), ok()]);
  // fail() が reject した瞬間にここを抜けて catch へ。
  // 残りの ok() の完了は待たない
} catch (e) {
  console.log("どれか1つこけた:", (e as Error).message);
}

「全部の結果(成功も失敗も)が欲しい」なら、Promise.allSettled を使う。これが感覚的には Task.WhenAll に近い。

const results = await Promise.allSettled([ok(), fail(), ok()]);
// results[i].status が 'fulfilled' か 'rejected'。全部の結末が取れる
for (const r of results) {
  console.log(r.status);
}

実行結果:

Promise.all と allSettled の動作確認 — all は fail-fast で1つの reject で即 catch、allSettled は fulfilled / rejected / fulfilled の3要素を全部返す証跡

Task.WhenAll のつもりで Promise.all を使うと、「片方こけたら、もう片方の結果が取れない」で詰まります。

詰まり④: async 関数は常に Promise を返す

C# の async Task<int> と同じで、TypeScript の async function戻り値を常に Promise で包みます

async function getValue(): Promise<number> {
  return 42; // return 42 でも、呼び出し側が受け取るのは Promise<number>
}

const n = await getValue(); // await して初めて number になる

await を付け忘れると、nnumber ではなく Promise<number> のまま。C# でも Task<int>await せずに使うと型が合わないので、ここは地続きです。

注意は、C# の async void に当たる投げっぱなし(fire-and-forget)

TS でも async functionawait せず呼べます。ただしその場合、中の例外が②の通り消えます。

投げっぱなしにするなら、せめて .catch() を付けて例外だけは拾っておくのが安全です。

// 投げっぱなしにするなら .catch だけは付ける
doSomethingAsync().catch((e) => console.error("拾った:", e));

詰まり⑤: ConfigureAwait に当たるものは無い

C# でライブラリを書くとき、ConfigureAwait(false) を付けて UI スレッドのデッドロックを避ける、という定番作法がありますよね。

TypeScript(JavaScript)には、これに当たるものがありません

理由は、JS が単一スレッドのイベントループで動いてるから。C# の SynchronizationContext(元のスレッドに戻る仕組み)がそもそも無いんですよね。

const res = await fetch("/api/data");
// await の後で「元のスレッドに戻る」という概念がない。
// だから ConfigureAwait のような指定も要らない

これは「JS の方が楽」という話じゃなくて、事情が違うだけ

JS は単一スレッドなので、逆に CPU が重い処理を裏で並列に回したいときは、Worker という別の仕組みが要ります。C# のスレッドプール感覚はそのまま持ち込めない。一長一短です。

動かして確かめる

①と②は、実際に動かすとこんな感じで一発で腑に落ちます。tsx などで TypeScript を直接実行してみてください。

// check.ts — Promise の eager 実行と await 忘れを観察する
console.log("start");
new Promise<void>((resolve) => {
  console.log("executor 実行");
  resolve();
});
console.log("end");
// 出力: start → executor 実行 → end (executor が同期で走る)
npx tsx check.ts

実行結果:

Promise の eager 実行と await 忘れの動作確認 — 出力が start → executor 実行 → end の順で、executor が同期実行されることと、await 忘れの例外が Unhandled rejection に消える証跡

executor 実行end より先に出れば、「Promise は生成即実行」が体感できます!!

俺の現場メモ

C# の Task 感覚のまま TS に入ると、まず②の await 忘れで一度は事故ります。

俺もやりました。例外が出てるはずのバッチが、エラーログに何も残さず静かに失敗してる。

え、なんで止まらへんの??ってなって、原因にたどり着くまでにけっこう時間を食った。ログには何も出てないんだから、そりゃ探すよなって。

でも、ハマったのは結局この5箇所くらい。残りの9割は「Task ↔ Promise」の対応で地続きでした。

C# で非同期を書いてきた経験は、ちゃんと効きます。ゼロから覚え直すわけじゃない。

おすすめは、こんな感じで手元に対応マップ(上の表)を置いて、ズレてる行だけ意識すること。

await 忘れに警告が出ない、Promise.all は fail-fast。この2つだけ体に入れておけば、TS の非同期で大コケすることはほぼ無くなります。

まとめ

C# の async/await 経験者が TypeScript で詰まる5つ、整理するとこうです。

  • ① Promise は new した瞬間に走る — cold Task は無い。遅延は関数で包む
  • ② await 忘れは警告ナシで例外が消える — Unhandled rejection。C# の CS4014 に守られてない
  • ③ Promise.all は fail-fast — 全部待ちたいなら allSettled(こっちが Task.WhenAll 感覚)
  • ④ async は常に Promise を返す — 投げっぱなしは .catch() で例外だけ拾う
  • ⑤ ConfigureAwait は無い — JS は単一スレッドで SynchronizationContext が無いだけ

Task の知識は捨てなくていい。ズレてる5箇所だけ上書きすれば、TypeScript の非同期もいい感じに書けます。

よくある質問

Q1. C# の Task と TS の Promise、根本的に何が一番違う?

開始タイミングと await 忘れの安全網です。

Promise は new した瞬間に走り(cold が無い)、await を忘れても C# のような警告が出ません。例外は Unhandled rejection に消えます。

並行系(Task.WhenAll ↔ Promise.all)の失敗挙動も違うので、その3点を押さえれば大半は地続きです。

Q2. Promise.all と Promise.allSettled、どっちを使えばいい?

「1つでも失敗したら全体を失敗にしたい」なら Promise.all(fail-fast)。「成功も失敗も全部の結果を集めたい」なら Promise.allSettled です。

C# の Task.WhenAll の「全部終わるまで待つ」感覚に近いのは allSettled の方です。

Q3. await を付け忘れても動くなら、付けなくていい時もある?

戻り値も例外も要らない「投げっぱなし」なら付けなくても動きます。ただしその場合は .catch() で例外だけは拾ってください。

付けないと例外が Unhandled rejection になり、原因不明の静かな失敗になります。基本は await を付けるのが安全です。

Q4. C# の CancellationToken に当たるものは TS にある?

標準の Promise にキャンセルは組み込まれていません。fetch などでは AbortController を使って中断します。

C# の CancellationToken ほど統一された仕組みではないので、ライブラリごとのキャンセル手段を確認する形になります。

関連記事

以上!

「C# の感覚で TS 書いて await 忘れた」経験ある人いたら、どんどんシェア待ってるぜ!!


執筆者

バイブス父さん — 業務SE 7年(SIer 正社員2 / フリーランス5)。現職は SEO 直轄部の AI アドバイザー兼 PL、副業で中小 SIer の CTO。SIer の正社員からフリーランスに転じ、複数のエージェント経由で案件を回してきた経験ベースで「業務SE視点」の技術 + キャリア記事を書いています。

🐦 X: @hiro_progra0524(日々の現場メモ更新中)
📝 About Me で経歴詳細を見る


この記事が気に入ったら
いいねしてね!

どんどんシェア待ってるぜ!!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次