Callback ke promise ke async/await

Evolusi pola async serta penanganan error pada tiap tahapnya.

Daftar isi

Node mengelola I/O secara non-blocking sehingga operasi yang belum selesai perlu menyerahkan kendali ke event loop dan melanjutkan pekerjaan lewat callback. Pola penanganan hasil I/O telah melalui tiga tahap evolusi: callback manual, promise sebagai wadah nilai masa depan, dan async/await yang menulis kode async dengan gaya sinkron. Topik ini menyusuri ketiganya beserta penanganan error pada tiap tahap, lalu membandingkan kapan komposisi menjadi sulit dan kapan blok try/catch tunggal cukup.

Pola callback dan konvensi error-first#

Callback adalah fungsi yang diteruskan sebagai argumen ke fungsi async, lalu dipanggil ketika hasil tersedia. Node menetapkan konvensi error-first: parameter pertama callback selalu err, dan parameter berikutnya memuat hasil. Konvensi ini mengikat seluruh modul inti Node seperti fs.readFile dan dns.lookup.

js callback-pyramid.js
function ambil(nomor, delay, cb) {
  setTimeout(() => cb(`data #${nomor}`), delay);
}

// Callback bersarang (callback hell)
ambil(1, 20, (a) => {
  ambil(2, 20, (b) => {
    ambil(3, 20, (c) => {
      console.log("callback bersarang:", [a, b, c]);
    });
  });
});
text Keluaran
callback bersarang: [ 'data #1', 'data #2', 'data #3' ]

Pola ini bekerja untuk satu langkah, tetapi komposisi tiga langkah berurutan menghasilkan piramida yang menjorok ke kanan. Setiap langkah baru menambah satu tingkat indentasi, sehingga alur data sulit ditelusuri dan penanganan error terpencar di banyak tempat. Karena callback tidak mengembalikan nilai yang dapat diperiksa pemanggil, pemanggil tidak memiliki cara untuk menyatakan “lakukan B setelah A berhasil” tanpa menyematkan logika B di dalam A.

Promise sebagai wadah nilai masa depan#

Promise adalah objek yang merepresentasikan nilai yang belum tersedia, dengan tiga keadaan: pending, fulfilled, dan rejected. Sekali promise fulfilled atau rejected, keadaannya tidak berubah lagi. Komposisi dilakukan lewat .then dan .catch, keduanya mengembalikan promise baru sehingga rantai dapat dilanjutkan tanpa menjorok. Berkas callback-vs-promise.js membandingkan ketiga pola dalam satu titik pemanggilan.

js callback-vs-promise.js
// Pola error-first callback
function ambilCallback(nomor, delay, gagal, cb) {
  setTimeout(() => {
    if (gagal) cb(new Error(`gagal #${nomor}`));
    else cb(null, `data #${nomor}`);
  }, delay);
}

ambilCallback(1, 30, false, (err, data) => {
  if (err) {
    console.log("callback error:", err.message);
    return;
  }
  console.log("callback hasil:", data);
});

// Pola promise yang menggantikan callback
function ambilPromise(nomor, delay, gagal) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (gagal) reject(new Error(`gagal #${nomor}`));
      else resolve(`data #${nomor}`);
    }, delay);
  });
}

ambilPromise(2, 20, false)
  .then((data) => console.log("promise hasil:", data))
  .catch((err) => console.log("promise error:", err.message));

// Pola async/await yang membungkus promise
async function jalankan() {
  try {
    const data = await ambilPromise(3, 10, false);
    console.log("async/await hasil:", data);
  } catch (err) {
    console.log("async/await error:", err.message);
  }
}

jalankan();
text Keluaran
async/await hasil: data #3
promise hasil: data #2
callback hasil: data #1

Keluaran tidak berurut sesuai nomor karena tiap pemanggilan memiliki delay yang berbeda: callback ambilCallback(1, 30) selesai paling akhir, sedangkan await ambilPromise(3, 10) selesai paling awal. Fakta ini menjadi peringatan bahwa urutan baris kode tidak menentukan urutan hasil pada program async.

async/await: promise dengan gaya sinkron#

async/await adalah sintaks yang membungkus promise sehingga kode async dapat ditulis seperti kode sinkron. Fungsi async selalu mengembalikan promise, sedangkan await menunda eksekusi sampai promise di sebelah kanannya settled. Karena await bekerja pada promise, modul berbasis callback perlu dibungkus dulu dengan util.promisify agar dapat di-await.

Keuntungan utama async/await adalah komposisi linear. Tiga langkah berurutan tidak lagi menjorok, dan penanganan error terkumpul dalam satu blok try/catch alih-alih tersebar pada banyak .catch per langkah. Berkas async-await-error.js menunjukkan satu try/catch yang menutup banyak await.

js async-await-error.js
function ambil(nomor, delay, gagal = false) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (gagal) reject(new Error(`gagal #${nomor}`));
      else resolve(`data #${nomor}`);
    }, delay);
  });
}

// Penanganan error konsisten: satu try/catch menangkap semua reject
async function jalankan() {
  try {
    const a = await ambil(1, 20);
    const b = await ambil(2, 10, true);
    const c = await ambil(3, 30);
    console.log("semua berhasil:", [a, b, c]);
  } catch (err) {
    console.log("tertangkap:", err.message);
  }
}

jalankan();
text Keluaran
tertangkap: gagal #2

Reject yang dilempar pada ambil(2, 10, true) langsung melompat ke catch, sehingga ambil(3, 30) tidak pernah dipanggil. Perilaku ini sama dengan Promise.all yang berhenti pada kegagalan pertama, dan menjadi dasar pemilihan pola konkurensi.

Penanganan error pada tiap tahap#

Setiap tahap evolusi memiliki disiplin penanganan error yang berbeda. Tabel berikut membandingkan titik tangkap dan keterbatasannya.

TahapTitik tangkap errorKeterbatasan
CallbackParameter err pada setiap callbackMudah terlewat, tersebar di banyak tempat
.then/.catchSatu .catch di akhir rantaiCabang paralel perlu .catch sendiri
async/awaitSatu try/catch menutup banyak awaitLoop forEach tidak meng-await tanpa pembungkus

Rantai promise versus await berurutan#

Rantai .then dan await berurutan saling setara secara semantik: keduanya menunggu promise sebelumnya settled sebelum memulai langkah berikutnya. Pilihan antara keduanya biasanya bergantung pada gaya yang lebih mudah dibaca untuk kasus tertentu. Rantai .then tetap berguna ketika transformasi bersifat deklaratif, misalnya memetakan nilai melalui beberapa fungsi murni tanpa percabangan.

Bentuk rantai ambil(1, 20).then((a) => ambil(2, 20).then((b) => [a, b])).then(([a, b]) => console.log(a, b)) dan bentuk await berurutan const a = await ambil(1, 20); const b = await ambil(2, 20); menghasilkan keluaran yang sama. Karena await menghentikan eksekusi sampai promise settled, dua await berurutan menjadi operasi serial dan total waktu adalah penjumlahan durasi kedua operasi. Bila kedua operasi tidak saling bergantung, sebut saja pengambilan dua berkas konfigurasi yang berbeda, gunakan Promise.all agar keduanya berjalan paralel. Kombinator lain seperti Promise.allSettled dan Promise.race menyediakan kebijakan penanganan kegagalan yang berbeda untuk kasus paralel.

Kesalahan umum#

Lupa return setelah menangani error di callback. Tanpa return, kode setelah blok if (err) tetap berjalan dan mengakses data yang tidak terdefinisi, sehingga error asli tertutup oleh TypeError baru yang lebih membingungkan.

Mengabaikan promise yang ditolak tanpa .catch. Promise yang rejected dan tidak tertangkap memunculkan unhandled rejection yang pada Node versi modern mengakhiri proses. Selalu akhiri rantai dengan .catch atau bungkus await di dalam try/catch.

Ringkasan#

  • Konvensi callback Node adalah error-first: parameter pertama selalu err.
  • Promise menyatukan keberhasilan dan kegagalan ke dalam satu objek dengan keadaan final yang tidak berubah, sehingga komposisi dapat dirantai tanpa menjorok.
  • async/await menulis ulang rantai promise menjadi blok linear dengan satu try/catch untuk banyak await.
  • forEach tidak meng-await callback async, sehingga for...of atau Promise.all menjadi pilihan yang aman.

Referensi#