ข้ามไปยังเนื้อหา

Replace Error Code with Exception

error code คือค่าคืนพิเศษ เช่น -1, null, false หรือ string ว่าง ที่ function ใช้บอกว่า “อันนี้ไม่สำเร็จ” ปัญหาคือหน้าตาเหมือนค่าปกติเป๊ะ caller จึงต้อง จำเอง ว่าต้องเช็ค ลืมครั้งเดียว sentinel ก็ไหลลงไปชั้นล่างราวกับเป็นข้อมูลจริง

Replace Error Code with Exception แยกสองช่องทางออกจากกัน ค่าคืนถือเฉพาะผลสำเร็จ; ความล้มเหลวเดินทางอีกเส้นที่ไม่อาจถูกเข้าใจผิดเงียบ ๆ ว่าเป็นความสำเร็จ ในภาษาที่มี exception (TypeScript, Python) คุณ throw/raise ใน Go และ Rust ไม่มี exception สำหรับความล้มเหลวธรรมดา — การย้ายที่เป็นสไตล์คือทำให้ error ชัดเจนใน type: คืน error ควบคู่กับค่าใน Go หรือ Result ใน Rust เราแสดงทั้งสอง และบอกว่าอันไหนเป็นอันไหน

function คืน -1 แปลว่า “ไม่พบ” คืน null แปลว่า “parse ไม่ได้” หรือคืน false แปลว่า “บันทึกไม่สำเร็จ” caller ทุกตัวจึงแบกภาระต้องเขียน check ที่ลืมได้ง่ายมาก และการลืมนั้นก็มองไม่เห็นจนกว่าค่าผิด ๆ จะไประเบิดที่ไหนสักแห่งไกล ๆ ที่แย่กว่านั้นคือ sentinel ตัวเดียวกันมักแปลคนละอย่างใน function คนละตัว เช่น -1 เป็น index ที่หาไม่เจอตรงนี้ แต่เป็นจำนวน error ตรงโน้น ผู้อ่านจึงมองแวบเดียวแล้วเชื่อไม่ได้ สรุปคือความล้มเหลวถูกลักลอบยัดเข้ามาในช่องทางของค่า ทั้งที่ไม่ควรอยู่ตรงนั้น

ตัวอย่างคือการถอนเงินจากบัญชี ตอนแรก function คืน -1 เมื่อเงินไม่พอ หลัง refactor จะส่งสัญญาณความล้มเหลวผ่านช่องทางของตัวเอง คือ exception ใน TS/Python และ error/Result แบบชัดเจนใน Go/Rust

// Before — caller must remember that -1 means failure
function withdraw(balance: number, amount: number): number {
if (amount > balance) {
return -1;
}
return balance - amount;
}
// After — failure cannot be confused with a balance
class InsufficientFundsError extends Error {}
function withdraw(balance: number, amount: number): number {
if (amount > balance) {
throw new InsufficientFundsError(`need ${amount}, have ${balance}`);
}
return balance - amount;
}
flowchart TD
  A["Function fails"] --> B{"How is failure reported?"}
  B -->|"Sentinel: -1 / null / false"| C["Caller must remember to check<br/>silent corruption if they forget"]
  B -->|"Exception (TS, Python)"| D["Failure cannot be ignored<br/>unwinds to a handler"]
  B -->|"error / Result (Go, Rust)"| E["Failure is explicit in the type<br/>compiler nudges you to handle it"]
ความล้มเหลวออกจากช่องทางของค่า — exception ใน TS/Python, error หรือ Result ใน Go/Rust
  1. ระบุว่า sentinel คือค่าไหนและแทนเงื่อนไขอะไร ปักหมุดให้ชัดว่ารายงานความล้มเหลวแบบใด
  2. เลือกช่องทางความล้มเหลวสำหรับภาษาของคุณ ใน TypeScript หรือ Python ให้นิยาม exception type เฉพาะ — class ที่มีชื่อดีกว่า Error/Exception เปล่า ๆ เพราะ caller catch ความล้มเหลว นี้ ได้โดยไม่กลืนความล้มเหลวที่ไม่เกี่ยวกันไปด้วย ใน Go ให้คืน error; ใน Rust ให้คืน Result ที่มี error แบบมี type
  3. ที่จุดความล้มเหลว แทนที่ return sentinel ด้วยสัญญาณใหม่: throw/raise หรือ return 0, err / return Err(...)
  4. อัปเดตเส้นทางสำเร็จให้คืนเฉพาะผลลัพธ์จริง — และใน Go/Rust ให้จับคู่กับ nil/Ok(...)
  5. ไล่แก้ caller ทุกตัว เปลี่ยน check แบบ “คืน sentinel มาหรือเปล่า” เป็นการจัดการที่เข้าคู่กัน คือ try/catch (หรือปล่อยให้ลอยขึ้นไป) สำหรับ exception ส่วน Go และ Rust ใช้ if err != nil หรือ ?/match
  6. รัน test หลังย้าย caller แต่ละราย เพื่อให้จุดที่พลาดโผล่ขึ้นมาทันทีแทนที่จะหลังจากเปลี่ยนทั้งหมดแล้ว

หยิบท่านี้มาใช้เมื่อ function รายงานความล้มเหลวด้วย sentinel ที่ปนอยู่ในช่องทางเดียวกับค่าปกติ และ sentinel นั้นมองข้ามง่ายหรือกำกวม พอย้ายความล้มเหลวขึ้นช่องทางของตัวเอง โอกาสจัดการพลาดก็ลดลงมาก exception ที่ไม่มีใครจับจะโผล่ออกมาดังลั่น ส่วน error/Result ที่ไม่ได้จัดการก็ยังมองเห็นได้ และใน Rust compiler ไม่ยอมให้คุณลืมเคส Err ด้วยซ้ำ

ข้อแลกเปลี่ยนต่างกันไปตามภาษา exception เหมาะกับเงื่อนไขที่ เป็นข้อยกเว้นจริง ๆ ไม่ใช่ผลลัพธ์ที่คาดว่าจะเจอเป็นกิจวัตร ถ้าเอามาใช้กับ control flow ปกติ code จะช้าและตามยาก ถ้าผลลัพธ์ “ไม่เจอ” เป็นเคสปกติที่คาดไว้อยู่แล้ว ให้เลือก type ว่างแบบชัดเจนแทน ดูเพิ่มที่ Introduce Special Case ส่วนใน Go และ Rust การใช้ error/Result แบบชัดเจนเป็นสไตล์ประจำอยู่แล้ว การ refactor ตรงนั้นจึงไม่ใช่ “เอา exception เข้ามา” แต่เป็น “เลิกเข้ารหัส error เป็นเลขวิเศษ” มากกว่า และไม่ว่าจะเลือกช่องทางไหน อย่าผสมสองแบบใน function เดียวกัน เพราะนั่นเพิ่มวิธีที่ caller จะทำพลาดเป็นสองเท่า

ใช้ Replace Error Code with Exception เมื่อหลีกเลี่ยงเมื่อ
sentinel value ดูเหมือนค่าปกติและถูกมองข้ามได้ง่าย”หายไป” เป็น expected case ปกติ — ใช้ Special Case แทน
ต้องการให้ failure ไม่สามารถถูก ignore ได้ใช้ exception สำหรับ control flow ปกติ ทำให้ code ช้า
caller ทุกรายต้องจัดการ failure ไม่ใช่แค่บางรายfunction เดียวกันผสมทั้ง exception และ sentinel

⚠️ ไม่ควร Replace Error Code with Exception เมื่อ:

  • failure นั้นเป็น normal expected case — exception มี cost และทำให้ trace ยาก
  • ภาษาใช้ Result/error เป็น convention แล้ว — นำ exception เข้ามาสร้าง inconsistency
  • ผสม exception กับ sentinel ใน function เดียวกัน — เพิ่มช่องทางที่ caller ทำผิดสองเท่า
ทำไม error code (เช่นการคืน -1) จึงเสี่ยง?
สำหรับ Go และ Rust บทเรียนนำเสนออะไรเป็น "Replace Error Code" ที่เป็นสไตล์แทน exception?
เมื่อผลลัพธ์ "ที่หายไป" เป็นเคสปกติที่คาดไว้มากกว่าความล้มเหลวจริง บทเรียนแนะนำอะไรแทน?
ทำไมจึงเลือก exception type เฉพาะมากกว่า Error/Exception เปล่า ๆ ใน TypeScript หรือ Python?