การ Wrap Error
ทำไมต้อง Wrap Error?
หัวข้อที่มีชื่อว่า “ทำไมต้อง Wrap Error?”เมื่อ function เรียกใช้อีก function แล้วล้มเหลว การคืน error เดิมทำให้เสียบริบท caller เห็นแค่ “database error” แต่ไม่รู้ว่า operation ใดที่ทำให้เกิด request ใดที่เกี่ยวข้อง หรือเกิดที่ layer ไหน การ wrap เพิ่มชั้น context ว่ากำลังทำอะไรอยู่ โดยยังเก็บ error เดิมไว้ให้ตรวจสอบได้
รูปแบบ idiomatic ใน Go:
if err != nil { return fmt.Errorf("description: %w", err)}%w คือกุญแจสำคัญ เพราะ wrap error เดิมไว้ภายใน error ใหม่ รักษา chain ทั้งหมดไว้สำหรับ caller ที่ต้องการตรวจสอบ
flowchart LR A["ErrNotFound (sentinel)"] -->|wrapped by queryDB| B["queryDB: %w"] B -->|wrapped by getUser| C["getUser: %w"] C -->|"errors.Is(err, ErrNotFound)"| D["true -- walks the whole chain"]
fmt.Errorf และ %w
หัวข้อที่มีชื่อว่า “fmt.Errorf และ %w”fmt.Errorf สร้าง error ใหม่ที่มี message รวมค่าอื่น แต่วิธีรวม error argument ขึ้นอยู่กับ verb ที่เลือก:
%vจัดรูปแบบ message string ของ error และฝังเป็น plain text type ของ error เดิมหายไป chain ถูกตัด%wบันทึก error เดิมไว้ใน error ใหม่ message ยังมี message เดิม แต่ error เดิมยังสามารถเข้าถึงได้โดยใช้โปรแกรม
base := errors.New("connection refused")
// %v — message only, chain is severede1 := fmt.Errorf("dial: %v", base)fmt.Println(errors.Is(e1, base)) // false
// %w — wraps, chain is preservede2 := fmt.Errorf("dial: %w", base)fmt.Println(errors.Is(e2, base)) // trueใช้ %w เมื่อต้องการให้ caller upstream สามารถระบุหรือดึง error เดิมออกมาได้ ใช้ %v เฉพาะเมื่อตั้งใจจะทิ้ง error type เดิม เช่น เมื่อ log และ chain ไม่มีความสำคัญ
errors.Is — ตรวจสอบ identity ผ่าน chain
หัวข้อที่มีชื่อว่า “errors.Is — ตรวจสอบ identity ผ่าน chain”errors.Is(err, target) เดิน unwrap chain ทั้งหมดของ err และคืนค่า true ถ้า error ใดใน chain ตรงกับ target การ match ทำโดย identity (==) ตามค่าเริ่มต้น ไม่ใช่ตาม message string
var ErrNotFound = errors.New("not found")
err := fmt.Errorf("getUser: %w", fmt.Errorf("queryDB: %w", ErrNotFound))
fmt.Println(errors.Is(err, ErrNotFound)) // true — found deep in the chainทำงานได้ไม่ว่า target จะถูก wrap ลึกแค่ไหน นิยาม sentinel error ครั้งเดียว (เป็น var ระดับ package) และตรวจสอบได้ทุกที่ใน call stack
errors.As — ดึง type จาก chain
หัวข้อที่มีชื่อว่า “errors.As — ดึง type จาก chain”errors.As(err, &target) เดิน chain เช่นกัน แต่แทนที่จะตรวจสอบ identity จะตรวจสอบ type ถ้า error ใดใน chain สามารถ assign ให้ type ของ target ได้ จะทำการ assign และคืน true
var ve *ValidationErrorif errors.As(err, &ve) { fmt.Println(ve.Field) // access fields on the concrete type}ใช้ errors.As เมื่อมี custom error type ที่เก็บข้อมูลเพิ่มเติม เช่น field names, HTTP status codes, retry hints และต้องการเข้าถึง field เหล่านั้น ไม่ใช่แค่ตรวจว่า error มีอยู่
errors.AsType — รูปแบบ generic (Go 1.26)
หัวข้อที่มีชื่อว่า “errors.AsType — รูปแบบ generic (Go 1.26)”Go 1.26 เพิ่ม errors.AsType[T] ทางเลือกแบบ type-safe ของ errors.As มันคืนค่าที่ match พร้อม bool จึงไม่มี pointer-to-pointer argument ให้เขียนผิด และ compiler ตรวจ T ให้:
if ve, ok := errors.AsType[*ValidationError](err); ok { fmt.Println(ve.Field)}code ใหม่ควรใช้ AsType; errors.As ยังอยู่สำหรับ toolchain เก่ากว่า (go fix migrate call site เดิมให้ได้)
Custom error types
หัวข้อที่มีชื่อว่า “Custom error types”struct ใดก็ตามที่ implement Error() string ตรงตาม error interface custom types ช่วยให้เก็บ structured context ควบคู่กับ message:
type ValidationError struct { Field string Message string}
func (e *ValidationError) Error() string { return fmt.Sprintf("validation: field %q %s", e.Field, e.Message)}เพื่อให้ custom error type เป็นส่วนหนึ่งของ wrapping chain ให้ implement Unwrap() error:
type AppError struct { Code int Err error}
func (e *AppError) Error() string { return fmt.Sprintf("code %d: %v", e.Code, e.Err) }func (e *AppError) Unwrap() error { return e.Err }เมื่อมี Unwrap แล้ว errors.Is และ errors.As สามารถมองผ่าน AppError เพื่อไปถึง error ที่ครอบอยู่ข้างใน
errors.As ต้องการ pointer ไปยัง concrete type
หัวข้อที่มีชื่อว่า “errors.As ต้องการ pointer ไปยัง concrete type”argument ตัวที่สองของ errors.As(err, target) ต้องเป็น non-nil pointer ไปยังตัวแปรที่ type ของมัน implement error หรือเป็น interface ถ้าผิดพลาดตรงนี้ errors.As จะ panic แทนที่จะคืน false — เป็นการออกแบบที่ตั้งใจ เพราะ target ที่ผิดมักเป็นความผิดพลาดของโปรแกรมเมอร์ ไม่ใช่เงื่อนไข runtime ที่ควรถูกกลืนไปเงียบ ๆ
ความผิดพลาดที่พบบ่อยที่สุดคือการส่งตัวแปร pointer เองแทนที่จะส่ง address ของมัน:
var ve *VErrerrors.As(err, ve) // BUG: passing ve, not &veตรวจสอบแล้วบน 1.26.5 โค้ดนี้ panic:
panic: errors: target must be a non-nil pointerve เป็น nil *VErr การส่งมันตรง ๆ ทำให้ errors.As ได้ pointer ที่ “ชนิด” ถูกต้องแต่ไม่มีที่เก็บข้อมูลที่ addressable — errors.As ต้องการ &ve (ซึ่งเป็น **VErr) เพื่อให้มีที่เก็บผลลัพธ์ ส่วน target ที่ type ที่ pointer ชี้ไปไม่ได้ implement error เลยจะ panic ด้วยข้อความอื่นที่จริงเช่นกัน:
var n interrors.As(err, &n) // BUG: *int does not implement errorpanic: errors: *target must be interface or implement errorerrors.Join และ %w หลายตัว
หัวข้อที่มีชื่อว่า “errors.Join และ %w หลายตัว”errors.Join (Go 1.20) รวม error หลายตัวเข้าเป็นค่าเดียวที่มี method Unwrap() []error เปิดเผย error ทั้งหมด errors.Is และ errors.As จะเดินทุกสาขา ดังนั้นการเรียก errors.Is ครั้งเดียวสามารถ match error ที่ join ไว้ตัวใดก็ได้:
joined := errors.Join(ErrDisk, ErrNet)fmt.Println(errors.Is(joined, ErrDisk)) // truefmt.Println(errors.Is(joined, ErrNet)) // truefmt.Errorf รองรับ %w มากกว่าหนึ่งตัวในการเรียกเดียวกันด้วยเช่นกัน ตั้งแต่ Go 1.20 ซึ่งให้ chain แบบหลาย error เหมือนกัน:
err := fmt.Errorf("save failed: %w, %w", ErrDisk, ErrNet)ทั้งสองรูปแบบทำให้ errors.Unwrap(err) — ซึ่งเข้าใจแค่รูปแบบ error เดียว Unwrap() error — คืน nil สำหรับมัน ใช้ errors.Is/errors.As ในการตรวจสอบ error ที่ join หรือ wrap หลายตัว อย่าคิดว่า errors.Unwrap ตัวเดียวจะไปถึงทุกสาขาได้
Custom Is และ As methods
หัวข้อที่มีชื่อว่า “Custom Is และ As methods”errors.Is และ errors.As จะตรวจหา method Is(target error) bool หรือ As(target any) bool บน error แต่ละตัวใน chain ก่อนจะกลับไปใช้การตรวจสอบ identity/type แบบ default สิ่งนี้ทำให้ type สามารถกำหนดเองได้ว่าอะไรถือว่า match:
// Is lets errors.Is treat every ValueErr as equal, regardless of msg --// useful when the exact message varies but the caller only cares// "was it this kind of error?"func (e ValueErr) Is(target error) bool { _, ok := target.(ValueErr) return ok}เมื่อมี method นี้แล้ว errors.Is(err, ValueErr{msg: "anything"}) จะรายงาน true สำหรับ ValueErr ใดก็ตาม ใน chain ไม่ใช่แค่ตัวที่มี field msg ตรงกันเป๊ะ ให้กำหนด As เองในลักษณะเดียวกันเมื่อการเทียบ type แบบ default ยังไม่ละเอียดพอ — เช่น การ match กับ error code ที่เก็บไว้ใน field แทนที่จะเป็น type ของ wrapper เอง
Receiver choice มีผลต่อ errors.As
หัวข้อที่มีชื่อว่า “Receiver choice มีผลต่อ errors.As”concrete type ที่ลงเอยอยู่ใน error chain คือ type ที่ implement error จริง ๆ — และนั่นขึ้นอยู่กับ receiver ที่เลือกใช้ pointer receiver หมายความว่ามีแค่ *T เท่านั้นที่ implement error ดังนั้น chain จะเก็บ *T และ errors.As ต้องการ target เป็น **T ส่วน value receiver หมายความว่า T implement error โดยตรง และการ wrap ค่า T (ไม่ใช่ &T{}) จะใส่ T ลงใน chain — errors.As ที่ใช้ target เป็น **T จะไม่เจออะไรเลย:
perr := fmt.Errorf("op: %w", &PtrErr{Code: 7}) // *PtrErr in the chainvar asPtr *PtrErrerrors.As(perr, &asPtr) // true
err := fmt.Errorf("op: %w", ValueErr{msg: "x"}) // ValueErr in the chainvar asValuePtr *ValueErrerrors.As(err, &asValuePtr) // false -- chain holds ValueErr, not *ValueErrvar asValue ValueErrerrors.As(err, &asValue) // true -- matches the value typeการเลือก receiver แบบใดแบบหนึ่งไม่ใช่เรื่องผิดในตัวมันเอง bug อยู่ที่การเขียน target ของ errors.As ราวกับว่า error ถูก wrap เป็น pointer ทั้งที่จริงถูก wrap เป็น value หรือในทางกลับกัน
%w กับ nil error
หัวข้อที่มีชื่อว่า “%w กับ nil error”การ wrap nil error เป็นสิ่งที่ทำได้ตามกฎ — fmt.Errorf ไม่ panic — แต่จะได้ message ที่แทบจะไม่ใช่สิ่งที่ตั้งใจ:
var inner error // nilerr := fmt.Errorf("op failed: %w", inner)fmt.Println(err) // op failed: %!w(<nil>)%w ต้องการ argument error ที่ไม่ใช่ nil เพื่อบันทึกไว้ เมื่อได้รับ nil fmt จะ fallback ไปใช้ format แบบ bad-verb ตามปกติและฝัง %!w(<nil>) ไว้ใน message การตรวจสอบ printf ของ go vet ตรวจกรณีนี้ไม่ได้ — type แบบ static ของ inner คือ error และ vet ไม่มีทางรู้ว่าค่าจริงเป็น nil ตอน compile time ป้องกันด้วย if err != nil ก่อน wrap เสมอ เหมือนวินัยที่ใช้ทุกที่
ไม่มี stack trace
หัวข้อที่มีชื่อว่า “ไม่มี stack trace”standard errors package ไม่เคยบันทึกว่า error ถูกสร้างที่ไหนหรือ goroutine ไหนแตะต้องมันบ้าง — Error() คืนแค่ string และนั่นคือ contract ทั้งหมด การ wrap ด้วย %w สร้าง chain ของ message และสาเหตุ ไม่ใช่ call stack เมื่อต้องการข้อมูลตำแหน่ง ต้องเพิ่มเองอย่างชัดเจน: จับ runtime.Caller หรือ runtime/debug.Stack() ตอนสร้าง error แล้วเก็บไว้เป็น field หรือใช้ third-party package ที่ออกแบบมาสำหรับเรื่องนี้ (github.com/pkg/errors ทำให้ errors.WithStack เป็นที่นิยม ก่อนที่ errors.Is/errors.As จะเข้ามาอยู่ใน standard library) นี่เป็น trade-off ที่ตั้งใจ — error ที่ถูกและ compose ได้ ไม่มีต้นทุนจนกว่าจะต้องการมากกว่านั้น
package main
import ( "errors" "fmt")
var ErrDisk = errors.New("disk full")var ErrNet = errors.New("network unreachable")
// PtrErr uses a pointer receiver -- the chain holds *PtrErr.type PtrErr struct{ Code int }
func (e *PtrErr) Error() string { return fmt.Sprintf("code %d", e.Code) }
// ValueErr uses a value receiver -- the chain holds ValueErr, not *ValueErr.type ValueErr struct{ msg string }
func (e ValueErr) Error() string { return e.msg }
// Is lets errors.Is treat every ValueErr as equal, regardless of msg --// useful when the exact message varies but the caller only cares "was it// this kind of error?"func (e ValueErr) Is(target error) bool { _, ok := target.(ValueErr) return ok}
func main() { // errors.Join combines independent failures into one error whose // Unwrap() []error exposes both -- errors.Is finds either one. joined := errors.Join(ErrDisk, ErrNet) fmt.Println(joined) fmt.Println("joined is ErrDisk:", errors.Is(joined, ErrDisk)) fmt.Println("joined is ErrNet:", errors.Is(joined, ErrNet))
// fmt.Errorf accepts more than one %w since Go 1.20. multi := fmt.Errorf("save failed: %w, %w", ErrDisk, ErrNet) fmt.Println(multi) // errors.Unwrap only understands Unwrap() error -- a multi-%w error // implements Unwrap() []error instead, so this is nil. fmt.Println("errors.Unwrap(multi):", errors.Unwrap(multi)) fmt.Println("multi is ErrNet:", errors.Is(multi, ErrNet))
// A custom Is method changes what errors.Is considers a match. err := fmt.Errorf("op: %w", ValueErr{msg: "specific detail"}) fmt.Println("custom Is match:", errors.Is(err, ValueErr{msg: "different detail"}))
// Receiver choice decides which pointer-ness errors.As must ask for. perr := fmt.Errorf("op: %w", &PtrErr{Code: 7}) var asPtr *PtrErr fmt.Println("errors.As *PtrErr:", errors.As(perr, &asPtr))
var asValuePtr *ValueErr fmt.Println("errors.As *ValueErr on value-receiver chain:", errors.As(err, &asValuePtr)) var asValue ValueErr fmt.Println("errors.As ValueErr on value-receiver chain:", errors.As(err, &asValue))
// %w with a nil error is a vet-flagged formatting mistake, not a crash. var nilErr error fmt.Println(fmt.Errorf("wrapped: %w", nilErr))} package main
import ( "errors" "fmt")
var ErrDatabase = errors.New("database error")
type ValidationError struct { Field string Message string}
func (e *ValidationError) Error() string { return fmt.Sprintf("validation: field %q %s", e.Field, e.Message)}
func queryDB(id int) (string, error) { if id < 0 { return "", ErrDatabase } if id == 0 { return "", &ValidationError{Field: "id", Message: "must be positive"} } return "Alice", nil}
func getUser(id int) (string, error) { name, err := queryDB(id) if err != nil { return "", fmt.Errorf("getUser %d: %w", id, err) } return name, nil}
func main() { _, err := getUser(-1) if err != nil { fmt.Println(err) fmt.Println("is ErrDatabase:", errors.Is(err, ErrDatabase)) }
_, err = getUser(0) if err != nil { fmt.Println(err) var ve *ValidationError if errors.As(err, &ve) { fmt.Printf("field=%q msg=%q\n", ve.Field, ve.Message) } }
name, err := getUser(1) if err != nil { fmt.Println("error:", err) return } fmt.Println("name:", name)} อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อโค้ดที่ AI generate เรียก errors.As หรือ wrap error หลายตัว ให้มองหาสิ่งเหล่านี้ก่อนเชื่อมัน:
errors.Asถูกเรียกด้วยตัวแปร pointer เองตรง ๆ (errors.As(err, ve)) แทนที่จะเป็น address ของมัน (errors.As(err, &ve)) หรือเปล่า? รูปแบบแรก panic ตอน runtime ไม่ใช่แค่คืนfalse- โค้ดเรียก
errors.Unwrap(err)โดยคาดว่าจะไปถึงทุก error ข้างในerrors.Joinหรือผลลัพธ์ multi-%wหรือเปล่า?Unwrap() []errorเป็นรูปแบบที่ต่างออกไป — มีแค่errors.Is/errors.Asเท่านั้นที่เดินมันได้ - receiver (value หรือ pointer) ของ custom error type ตรงกับความเป็น pointer ของ target ที่
errors.Asใช้เทียบกับมันในที่อื่น ๆ ของโค้ดหรือเปล่า? fmt.Errorf("...: %w", err)มีโอกาส run ตอนerrเป็นnilหรือเปล่า? ต้องเช็กที่ caller ไม่ใช่แค่บรรทัดนี้บรรทัดเดียว —go vetจะไม่จับกรณีนี้ให้- โค้ดสมมติว่า error มี stack trace หรือเลขบรรทัดมาให้โดย default หรือเปล่า? standard library ไม่เคยแนบมาให้เลย
🐛 หา bug ในโค้ดนี้
type NetErr struct{ Op string }
func (e *NetErr) Error() string { return e.Op + ": timeout" }
func handle(err error) { var ne *NetErr if errors.As(err, ne) { fmt.Println("net error on", ne.Op) }}
func main() { handle(fmt.Errorf("dial: %w", &NetErr{Op: "dial"}))}เฉลย
ne ถูกประกาศเป็น var ne *NetErr — nil *NetErr — แล้วส่งให้ errors.As ตรง ๆ แทนที่จะเป็น &ne errors.As ต้องการ address ของตัวแปร target เพื่อเขียนผลการ match ลงไป เมื่อได้รับ nil pointer เองก็ไม่มีที่ให้เขียน ตรวจสอบแล้วบน 1.26.5 โค้ดนี้ panic: panic: errors: target must be a non-nil pointer ทางแก้คือ errors.As(err, &ne)
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
| fmt.Errorf(“%w”, err) | wrap พร้อม context, errors.Is/As ทำงานได้ | verbose กว่า return err เฉยๆ |
| errors.Is() ใน chain | ตรวจ sentinel ใน chain ลึกแค่ไหนก็ได้ | ช้ากว่า == comparison (traversal) |
| custom error type | เก็บ structured context (field, code, status) | ต้อง maintain type, implement Error() |
| %v vs %w ใน fmt.Errorf | %v แสดง message ใน string, %w preserve chain | สับสนง่ายระหว่างสอง verb |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- wrap error ทุกชั้นยิ่งให้ context ยิ่งดี — wrapping ลึกเกินไปทำ error message อ่านยาก ให้ wrap เฉพาะเมื่อ caller ต้องการ context นั้น
- errors.Unwrap เดินทั้ง chain ให้ — ที่จริง
errors.Unwrapถอด wrapper ออกแค่ ชั้นเดียว ต่อการเรียกหนึ่งครั้ง และคืนnilสำหรับ error ที่มีUnwrap() []errorแทนUnwrap() error— เช่นผลลัพธ์ของerrors.Joinหรือfmt.Errorfที่มี%wมากกว่าหนึ่งตัว ต้องการเดินทั้ง chain หรือหาตัวที่ตรงกัน ใช้errors.Is/errors.Asแทน - %w ใน fmt.Errorf รองรับ error เดียวเท่านั้น — Go 1.20+ รองรับ
fmt.Errorf("msg: %w and %w", e1, e2)เพื่อ wrap หลาย error - errors.Is เปรียบเทียบ message string — errors.Is ตรวจ identity (
==) ไม่ใช่ message string — sentinel errors ต้องเป็น package-level var