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 failurefunction withdraw(balance: number, amount: number): number { if (amount > balance) { return -1; } return balance - amount;}
// After — failure cannot be confused with a balanceclass InsufficientFundsError extends Error {}
function withdraw(balance: number, amount: number): number { if (amount > balance) { throw new InsufficientFundsError(`need ${amount}, have ${balance}`); } return balance - amount;}# Before — caller must remember that -1 means failuredef withdraw(balance, amount): if amount > balance: return -1 return balance - amount
# After — raising makes failure impossible to ignoreclass InsufficientFundsError(Exception): pass
def withdraw(balance, amount): if amount > balance: raise InsufficientFundsError(f"need {amount}, have {balance}") return balance - amount// Go has no exceptions for ordinary failures. The idiomatic// "Replace Error Code" is to return an explicit error value, so// the failure is part of the signature and callers must handle it.//// Before — -1 sentinelfunc Withdraw(balance, amount int) int { if amount > balance { return -1 } return balance - amount}
// After — explicit errorvar ErrInsufficientFunds = errors.New("insufficient funds")
func Withdraw(balance, amount int) (int, error) { if amount > balance { return 0, fmt.Errorf("%w: need %d, have %d", ErrInsufficientFunds, amount, balance) } return balance - amount, nil}// Rust likewise avoids exceptions for recoverable errors. The// idiomatic move is to return a Result, making failure explicit in// the type — the compiler then nudges every caller to handle it.//// Before — -1 sentinelfn withdraw(balance: i64, amount: i64) -> i64 { if amount > balance { return -1; } balance - amount}
// After — explicit Result#[derive(Debug)]struct InsufficientFunds { need: i64, have: i64,}
fn withdraw(balance: i64, amount: i64) -> Result<i64, InsufficientFunds> { if amount > balance { return Err(InsufficientFunds { need: amount, have: balance }); } Ok(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"] กลไกการทำงาน
หัวข้อที่มีชื่อว่า “กลไกการทำงาน”- ระบุว่า sentinel คือค่าไหนและแทนเงื่อนไขอะไร ปักหมุดให้ชัดว่ารายงานความล้มเหลวแบบใด
- เลือกช่องทางความล้มเหลวสำหรับภาษาของคุณ ใน TypeScript หรือ Python ให้นิยาม exception type เฉพาะ — class ที่มีชื่อดีกว่า
Error/Exceptionเปล่า ๆ เพราะ caller catch ความล้มเหลว นี้ ได้โดยไม่กลืนความล้มเหลวที่ไม่เกี่ยวกันไปด้วย ใน Go ให้คืนerror; ใน Rust ให้คืนResultที่มี error แบบมี type - ที่จุดความล้มเหลว แทนที่
return sentinelด้วยสัญญาณใหม่:throw/raiseหรือreturn 0, err/return Err(...) - อัปเดตเส้นทางสำเร็จให้คืนเฉพาะผลลัพธ์จริง — และใน Go/Rust ให้จับคู่กับ
nil/Ok(...) - ไล่แก้ caller ทุกตัว เปลี่ยน check แบบ “คืน sentinel มาหรือเปล่า” เป็นการจัดการที่เข้าคู่กัน คือ
try/catch(หรือปล่อยให้ลอยขึ้นไป) สำหรับ exception ส่วน Go และ Rust ใช้if err != nilหรือ?/match - รัน 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 ทำผิดสองเท่า