Skip to content

บทที่ 6 — Error Handling + Debugging: ทำให้ server ไม่ตายง่าย ๆ

← บทที่ 5: HTTP + Web Server | สารบัญ | บทที่ 7: Config, CLI + Lifecycle →

ใน Node error 1 ตัวที่จัดการไม่ดี อาจทำให้ server ทั้งตัวตาย (จำได้ไหม — thread เดียว บทที่ 0) บทนี้สอนวิธีจัดการ error ให้ถูก แล้วต่อด้วยวิธี debug แบบมือโปร (ไม่ใช่ console.log รัว ๆ):

  1. Error object + custom error + err.code
  2. try/catch ครอบคลุมแค่ไหน — sync, await, แต่ไม่ครอบ callback/promise ที่ลืม catch
  3. operational vs programmer error — error แบบไหนควร "ตอบ user" แบบไหนควร "ตาย"
  4. uncaughtException / unhandledRejection — ตาข่ายชั้นสุดท้าย + นโยบายที่ถูก
  5. error handling ใน Express (รวม async)
  6. debug ด้วย --inspect + breakpoint แทน console.log

1. Error object และ custom error

JavaScript throw อะไรก็ได้ แต่ ควร throw Error เสมอ เพราะมันมี message + stack (ร่องรอยว่าพังที่ไหน):

javascript
throw new Error("อะไรพังสักอย่าง");

// custom error — สร้าง class ของเราเพื่อแยกประเภท error (จับเฉพาะที่ต้องการได้)
class ValidationError extends Error {
  constructor(message, field) {
    super(message);
    this.name = "ValidationError";   // ให้ stack/log บอกชื่อชัด
    this.field = field;              // ข้อมูลเสริม
    this.statusCode = 400;           // เอาไว้ map เป็น HTTP status ทีหลัง
  }
}

try {
  throw new ValidationError("email ไม่ถูกต้อง", "email");
} catch (err) {
  if (err instanceof ValidationError) {
    console.log(`ฟิลด์ ${err.field}: ${err.message}`);   // จับเฉพาะ ValidationError
  } else {
    throw err;   // error อื่นโยนต่อ — อย่ากลืน error ที่ไม่รู้จัก
  }
}

💡 core API มักแนบ err.code (string คงที่ เช่น ENOENT, ECONNREFUSED) — เช็ก err.code แม่นกว่าอ่าน err.message (ข้อความเปลี่ยน/แปลภาษาได้ แต่ code ไม่เปลี่ยน)


2. try/catch ครอบคลุมแค่ไหน — จุดที่มือใหม่พังบ่อยสุด

try/catch จับได้เฉพาะ error ที่เกิด แบบ synchronous และ error จาก await เท่านั้น มันจับ error ที่เกิดใน callback หรือ Promise ที่ไม่ได้ await ไม่ได้:

javascript
// ✅ จับได้: sync error
try {
  JSON.parse("ไม่ใช่ json");
} catch (err) { /* จับได้ */ }

// ✅ จับได้: await ที่ reject
try {
  await readFile("ไม่มีไฟล์.txt");
} catch (err) { /* จับได้ */ }

// ❌ จับไม่ได้: error ใน callback async — มันเกิด "หลัง" try block จบไปแล้ว
try {
  setTimeout(() => {
    throw new Error("พังใน callback");   // ⚠️ ตัวนี้หลุดออกไป crash โปรแกรม!
  }, 100);
} catch (err) {
  // ไม่มีทางมาถึงตรงนี้ — try block จบไปก่อน setTimeout ทำงานแล้ว
}

// ❌ จับไม่ได้: Promise ที่ลืม await/catch
try {
  readFile("ไม่มีไฟล์.txt");   // ไม่มี await! → กลายเป็น unhandledRejection
} catch (err) {
  // ไม่เข้า — เพราะไม่ได้ await Promise นี้
}

⚠️ กฎเหล็ก: Promise ทุกตัวต้องถูก await (ในฟังก์ชัน async ที่มี try/catch) หรือมี .catch() ต่อท้าย — Promise ที่ลืมจัดการเรียกว่า unhandled rejection (รายละเอียดในข้อ 4) และจะลอยไปถึง process

💡 Error.cause (Node 16.9+) — เวลา wrap error ด้วยข้อความใหม่ ให้แนบ error เดิมไว้ใน cause เพื่อไม่ทำให้ stack เดิมหาย:

javascript
try {
  await readFile("config.json", "utf8");
} catch (err) {
  throw new Error("โหลด config ไม่ได้", { cause: err });   // err เดิมยังอยู่ใน .cause
}

log จะเห็นเป็น chain: error ใหม่ → [cause]: error เดิม — debug ง่ายกว่า throw ใหม่เฉย ๆ ที่ทำ stack หาย


3. Operational error vs Programmer error — ตัดสินใจว่า "ตอบ" หรือ "ตาย"

แยกให้ออกก่อนตัดสินใจจัดการ:

ประเภทคือตัวอย่างทำยังไง
Operationalerror ที่ "คาดไว้แล้ว" ในระบบที่ทำงานปกติinput ผิด, ไฟล์ไม่เจอ, DB timeout, 404จัดการ + ตอบ user (เช่น 400/404/503) — server ทำงานต่อ
Programmerบั๊กในโค้ดเราอ่าน property ของ undefined, เรียก function ที่ไม่มี, typoปล่อยให้ crash แล้ว restart — เพราะ state อาจเสียหายแล้ว เชื่อถือไม่ได้

หลักคิดสำคัญ (ขัดสัญชาตญาณมือใหม่): อย่าพยายาม "จับทุก error แล้วรันต่อ" ถ้าจับ programmer error (บั๊ก) แล้วปล่อยให้โปรแกรมรันต่อ ระบบจะอยู่ใน state ที่เพี้ยนโดยไม่รู้ตัว อาจส่งข้อมูลผิดให้ user โดยที่ไม่มีใครสังเกตเห็น การ crash แล้ว restart สะอาดกว่า เพราะได้ state ใหม่ที่ไม่เสียหาย (process manager จะ restart ให้อัตโนมัติ — บทที่ 11)


4. ตาข่ายชั้นสุดท้าย: uncaughtException + unhandledRejection

เมื่อ error หลุดทุกชั้น มันจะมาถึง process — Node ยิง 2 event นี้:

javascript
// error sync ที่ไม่มีใครจับเลย
process.on("uncaughtException", (err) => {
  console.error("💥 uncaughtException:", err);
  // log ให้ครบ แล้ว "ตายอย่างมีศักดิ์ศรี" — อย่ารันต่อใน state ที่ไม่รู้
  process.exit(1);
});

// Promise ที่ reject แต่ไม่มีใคร .catch / await
process.on("unhandledRejection", (reason) => {
  console.error("💥 unhandledRejection:", reason);
  // ในของจริงควรเรียก graceful shutdown (บทที่ 7) ให้ request ที่ค้างจบก่อน
  // แล้วค่อย exit — ไม่งั้น user กลางคันจะเจอ ECONNRESET
  process.exit(1);
});

💡 Node 15+ เปลี่ยน default: unhandled rejection จะทำให้ process exit อัตโนมัติ (เหมือน uncaughtException) — ปรับได้ด้วย flag --unhandled-rejections=strict|warn|throw|none:

  • throw (default ตั้งแต่ 15) — exit process
  • warn — log warning แต่รันต่อ (พฤติกรรมเก่า Node 14)
  • strict — exit แม้จะมี handler
  • none — เงียบสนิท (อันตราย ไม่แนะนำ)

นโยบายที่ถูกต้อง (ตามคำแนะนำทางการของ Node):

  • 2 handler นี้มีไว้ log + ปิดให้เรียบร้อย + exitไม่ใช่ มีไว้ "กลืน error แล้วรันต่อ"
  • หลัง uncaughtException process อยู่ใน state ที่เชื่อไม่ได้ → ควร exit แล้วให้ process manager (pm2/Kubernetes — บทที่ 11) restart ตัวใหม่ที่สะอาด
    • pm2/Kubernetes (k8s) คือเครื่องมือที่คอย restart process/container ให้อัตโนมัติเมื่อ crash — รายละเอียดเต็มอยู่บทที่ 11
  • ก่อน exit ถ้าเป็น server ควรเรียก graceful shutdown (บทที่ 7) ให้ request ที่ค้างจบก่อน (drain in-flight) ไม่งั้น user กลางคันจะเจอ connection reset
  • มองมันเป็น "กล่องดำของเครื่องบิน" — มีไว้บันทึกเหตุการณ์ก่อนตก ไม่ใช่เครื่องมือซ่อมระหว่างบิน

⚠️ Pitfall: เห็นโค้ดที่ process.on("uncaughtException", () => {}) แล้วไม่ทำอะไร (กลืน error เงียบ ๆ ให้ server ไม่ตาย) — อันตรายมาก เพราะบั๊กถูกซ่อน ระบบทำงานผิดโดยไม่มีใครรู้ จนข้อมูลเสียหาย


5. Error handling ใน Express

Express มี error middleware พิเศษ — สังเกตว่ามี 4 พารามิเตอร์ (err, req, res, next) Express รู้ว่าเป็น error handler จากจำนวน argument:

javascript
import express from "express";
const app = express();
app.use(express.json());

// custom error จากข้อ 1 (สมมุติว่าประกาศไว้ตามแบบ ValidationError ข้างบน — statusCode = 400)
class ValidationError extends Error {
  constructor(message) {
    super(message);
    this.name = "ValidationError";
    this.statusCode = 400;
  }
}

app.get("/users/:id", (req, res) => {
  const id = Number(req.params.id);
  if (Number.isNaN(id)) throw new ValidationError("id ต้องเป็นตัวเลข");  // sync throw → Express จับได้
  res.json({ id });
});
// ⚠️ ที่จับได้ตรงนี้เพราะเป็น throw แบบ "sync" (ไม่มี async/await) — ถ้า handler เป็น async แล้ว throw/reject
// ข้างใน (เช่น Promise ที่ await แล้ว fail) พฤติกรรมจะต่างกัน ดูตัวอย่างถัดไปด้านล่าง

// ⭐ error middleware ต้องอยู่ "ท้ายสุด" และมี 4 args — จุดรวม error ทั้งแอป
app.use((err, req, res, next) => {
  console.error(err);   // log error จริงไว้ฝั่ง server (ใน production ใช้ pino — ดูข้อ 6)
  const status = err.statusCode ?? 500;
  // ⚠️ อย่าส่ง err.message ของ 5xx กลับ client — อาจรั่ว stack/DB error/path ภายใน
  //    4xx (operational) ส่ง message ได้ เพราะเราตั้งใจให้ user เห็น
  const body = status >= 500
    ? { error: "Internal Server Error" }
    : { error: err.message };
  res.status(status).json(body);
});

app.listen(3000);

ปัญหา: Express (4.x) ไม่จับ error จาก async handler อัตโนมัติ

javascript
// ❌ Express 4: ถ้า async handler reject → ไม่เข้า error middleware → unhandledRejection!
app.get("/bad", async (req, res) => {
  const data = await readFile("ไม่มีไฟล์");   // reject → หลุด!
  res.json(data);
});

// ✅ วิธีแก้ที่ 1: ครอบ try/catch + next(err) เอง
// (ในไฟล์จริง import ต้องอยู่ "บนสุดของไฟล์" เสมอ ตามกฎ ESM static import จากบทที่ 1
//  ที่เขียนไว้ตรงนี้เพื่อให้ตัวอย่างนี้อ่านจบในตัวเอง — ไม่ต้องเลื่อนขึ้นไปดูข้างบน)
import { readFile } from "node:fs/promises";

app.get("/ok", async (req, res, next) => {
  try {
    const data = await readFile("config.json", "utf8");
    res.json(JSON.parse(data));
  } catch (err) {
    next(err);   // ส่งเข้า error middleware
  }
});

💡 หมายเหตุเวอร์ชัน: Express 5 (เสถียรแล้วในปี 2025) จับ error จาก async handler ให้อัตโนมัติ ไม่ต้อง try/catch ทุก route แล้ว — ถ้าใช้ Express 4 อยู่ ครอบเองหรือใช้ helper เช่น express-async-errors

หมายเหตุล่วงหน้า: NestJS ก็แก้ปัญหานี้ให้ตั้งแต่แรก (มี exception filter จับ async error อัตโนมัติ) — ถ้ายังไม่ถึงภาค Nest ข้ามได้ จะมีบทเฉพาะที่ NestJS บทที่ 3


6. Debugging แบบมือโปร — เลิก console.log รัว ๆ

console.log ใช้ได้ แต่มีขีดจำกัด: ต้องเดาว่าจะ log ตรงไหน, log เยอะจนรก, ดู state ทั้งหมด ณ จุดหนึ่งไม่ได้ debugger จริงให้คุณหยุดโปรแกรม (breakpoint) แล้วส่องตัวแปรทุกตัว

วิธีที่ 1: node --inspect + Chrome DevTools

bash
node --inspect app.js           # เปิด debugger port (รันต่อทันที)
node --inspect-brk app.js       # หยุดที่บรรทัดแรกทันที (debug ตอน startup)
node --inspect-wait app.js      # (Node 18+) รัน แต่ "รอ" จน debugger ต่อค่อยเริ่ม

แล้วเปิด Chrome ไปที่ chrome://inspect → กด "inspect" → ได้ DevTools เต็มรูปแบบ (breakpoint, watch, call stack, console) เหมือน debug frontend

ขั้นตอนละเอียด (เผื่อทำครั้งแรก):

  1. รัน node --inspect-brk app.js ใน terminal — จะเห็นข้อความ Debugger listening on ws://127.0.0.1:9229/...
  2. เปิด Chrome → พิมพ์ chrome://inspect ใน address bar
  3. ใต้ "Remote Target" จะเห็นไฟล์ของเรา → กด "inspect" → DevTools เปิดขึ้นมา
  4. โค้ดหยุดที่บรรทัดแรก → ตั้ง breakpoint ที่บรรทัดอื่นได้ (คลิกซ้ายของเลขบรรทัด) → กด ▶ Resume (F8) → โปรแกรมรันไปหยุดที่ breakpoint ถัดไป
  5. ส่องตัวแปรในแท็บ Scope, ใส่ expression ใน Watch, เดินทีละบรรทัดด้วย Step Over (F10) / Step Into (F11)

💡 รัน test ด้วย debugger ก็ใช้แนวเดียวกัน: node --inspect-brk --test tests/ แล้วต่อ DevTools — set breakpoint ใน test/source code ได้เหมือนกัน

วิธีที่ 2: VS Code debugger (แนะนำที่สุด)

VS Code มี debugger ในตัว — วิธีเร็วสุด:

  1. เปิดไฟล์ที่จะ debug
  2. คลิกซ้ายของเลขบรรทัด → จุดแดง (breakpoint) โผล่
  3. กด F5 → เลือก "Node.js" → โปรแกรมรันแล้วหยุดที่ breakpoint
  4. ส่องตัวแปร, กด step over (F10) / step into (F11) ทีละบรรทัด

หรือใส่คำว่า debugger; ในโค้ด — เมื่อรันด้วย debugger จะหยุดตรงนั้นเอง:

javascript
app.get("/users/:id", (req, res) => {
  const id = Number(req.params.id);
  debugger;   // โปรแกรมหยุดตรงนี้ (ตอนรันใน debug mode) ให้ส่อง id, req
  res.json({ id });
});

console ก็มีของดีกว่า log

javascript
console.error("error ไป stderr");        // stderr = ช่องสำหรับ error (คู่กับ stdout = ช่อง output ปกติ) → production แยก error log ออกได้
console.table([{ id: 1 }, { id: 2 }]);   // แสดง array ของ object เป็นตารางสวย
console.time("db"); /* ...งาน... */ console.timeEnd("db");   // จับเวลา → "db: 42ms"
console.dir(obj, { depth: null });        // ดู object ลึกทุกชั้น

💡 ใน production อย่าใช้ console.log เป็น logging จริง — ใช้ structured logger เช่น pino (เร็วที่สุด, async-safe) หรือ winston ที่ log เป็น JSON มี level/timestamp พร้อม redact ฟิลด์ลับ:

javascript
import pino from "pino";
const log = pino({ level: process.env.LOG_LEVEL ?? "info" });
log.info({ userId: 42, requestId }, "user logged in");
log.error({ err }, "DB query failed");   // serialize stack ให้อัตโนมัติ

pino/winston รองรับการ redact (= ปิดบัง/ลบข้อมูลลับ เช่น password, token ก่อน log ออกไป) ฟิลด์ลับอัตโนมัติ กันไม่ให้ข้อมูลอ่อนไหวหลุดไปอยู่ใน log

ผูกกับ AsyncLocalStorage เพื่อแนบ requestId ทุก log line ของ request เดียวกัน (correlation) — debug production ง่ายขึ้นมาก · รายละเอียดเต็มในบทที่ 11 · console.log ไว้ debug ตอน dev เท่านั้น

💡 อยาก trace ลึก ๆ ใช้ node:diagnostics_channel (built-in) ปล่อย event ของแอป + ฟัง process.on("warning", ...) เพื่อจับ deprecation warning เร็วก่อนเครื่องจริงเจอ


🛠️ Checkpoint 6 — ลงมือก่อนไปบทถัดไป

  1. สร้าง class NotFoundError extends Error ที่มี statusCode = 404 แล้ว throw + จับด้วย instanceof
  2. เขียนโค้ดที่พิสูจน์ว่า try/catch จับ error ใน setTimeout callback ไม่ได้ — แล้วแก้ให้จับได้ (ใบ้: try/catch ต้องอยู่ "ข้างใน" callback)
  3. ตั้ง handler unhandledRejection แล้วจงใจสร้าง Promise ที่ reject โดยไม่ await — ดูว่า handler ทำงาน
  4. ใน Express ลองทำ async route ที่ throw (Express 4) แล้วดูว่ามันหลุดเป็น unhandledRejection จริงไหม → แก้ด้วย try/catch + next(err) → ใส่ error middleware ท้ายสุดให้ตอบ JSON สวย ๆ
  5. รันโปรแกรมด้วย node --inspect-brk แล้วต่อ Chrome DevTools (chrome://inspect) ตั้ง breakpoint แล้วส่องตัวแปร — หรือใช้ VS Code กด F5
  6. ใช้ console.time/timeEnd วัดว่าฟังก์ชันหนึ่งใช้เวลาเท่าไหร่

เฉลยข้อ 2:

javascript
setTimeout(() => {
  try {                          // try/catch ต้องอยู่ในตัว callback เอง
    throw new Error("พัง");
  } catch (err) {
    console.error("จับได้แล้ว:", err.message);
  }
}, 100);

สรุปบทที่ 6

  • throw new Error เสมอ (มี stack) · custom error (extends Error) แยกประเภทได้ · เช็ก err.code แม่นกว่า message
  • try/catch จับได้เฉพาะ sync + await — ไม่จับ error ใน callback หรือ Promise ที่ลืม await
  • แยก operational error (input ผิด/ไฟล์ไม่เจอ → จัดการ+ตอบ user) จาก programmer error (บั๊ก → ปล่อย crash + restart)
  • uncaughtException/unhandledRejection = ตาข่ายสุดท้าย → log แล้ว exit อย่ากลืน error รันต่อ
  • Express: error middleware 4 args (err, req, res, next) ท้ายสุด · Express 4 ต้องครอบ async เอง (next(err)), Express 5/NestJS จัดการให้
  • debug ด้วย --inspect + DevTools / VS Code breakpoint / debugger; แทน console.log รัว ๆ

บทต่อไป: จัดการ config ผ่าน environment (12-factor), ทำ CLI tool, และ graceful shutdown — ปิด server โดยไม่ทิ้ง request ค้าง

→ บทที่ 7: Config, CLI + Lifecycle