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

Error Values

type error ของ Go คือ interface ที่เรียบง่ายที่สุดใน standard library:

type error interface {
Error() string
}

type ใดก็ตามที่ implement method เดียว — Error() string — จะ satisfy interface นี้และสามารถใช้ได้ทุกที่ที่ต้องการ error ค่า nil เป็น error ที่ถูกต้องและหมายถึง “ไม่มี error เกิดขึ้น” โดยสากล ไม่มีอะไรพิเศษเกี่ยวกับ type error ในระดับภาษา เป็นแค่ interface ธรรมดาที่ predeclare ไว้ล่วงหน้าเท่านั้น

convention แบบ idiomatic ของ Go คือการคืนค่า (T, error) เป็นสองค่าสุดท้ายจาก function ที่อาจล้มเหลวได้ caller รับทั้งคู่เสมอและต้องตรวจสอบ error ก่อนที่จะเชื่อถือผลลัพธ์:

result, err := riskyOperation()
if err != nil {
// do not use result — it is meaningless
return fmt.Errorf("riskyOperation: %w", err)
}
// err is nil: result is valid

%w รักษา chain ไว้ให้ caller ยังตรวจสอบ error เดิมด้วย errors.Is/errors.As ได้ — จะกล่าวถึงในบทเรียนถัดไป

การคืนค่า error ให้ caller เป็นบรรทัดฐาน การกลืน error อย่างเงียบ ๆ — ละเว้น err ด้วย _ — ควรทำอย่างตั้งใจและเกิดขึ้นน้อยมาก ใช้เฉพาะในกรณีที่ความล้มเหลวไม่มีผลจริง ๆ (เช่น การปิดไฟล์แบบ read-only หลังจากอ่านข้อมูลทั้งหมดสำเร็จแล้ว)

Go มีสองวิธีมาตรฐานในการสร้าง error value ใหม่:

errors.New สร้าง error จาก string คงที่ ใช้เมื่อข้อความไม่เคยเปลี่ยนแปลง:

var errTimeout = errors.New("operation timed out")

fmt.Errorf สร้างข้อความ error แบบ formatted ใช้เมื่อต้องการรวม runtime values เช่น ชื่อ, key หรือจำนวน:

err := fmt.Errorf("user %d not found in database %q", userID, dbName)

หมายเหตุ: %v format ค่าแบบ inline และไม่แนบ wrapping metadata การ wrap — ใช้ %w เพื่อเชื่อม error ต่อกันเพื่อให้ errors.Is และ errors.As สามารถ unwrap chain ได้ — จะกล่าวถึงในบทเรียนถัดไป

sentinel error คือตัวแปรระดับ package ที่แทนเงื่อนไขความล้มเหลวเฉพาะที่รู้จักกันดี:

var ErrNotFound = errors.New("not found")
var ErrPermission = errors.New("permission denied")

การประกาศ sentinel ในระดับ package ให้ caller ทดสอบด้วย identity ใช้ errors.Is แทน == เพื่อให้การตรวจสอบยังทำงานได้แม้ว่า error จะถูก wrap ด้วย context เพิ่มเติม:

if errors.Is(err, ErrNotFound) {
// handle the not-found case
}

Sentinel เหมาะที่สุดสำหรับเงื่อนไขที่ caller ต้องการ branch แยกทาง หลีกเลี่ยงการสร้าง sentinel สำหรับทุกความล้มเหลวที่เป็นไปได้ — ใช้เฉพาะสำหรับ error ที่ caller จัดการแตกต่างจากกรณีล้มเหลวทั่วไปอย่างแท้จริง

การตรวจสอบ if err != nil อย่างชัดเจนอาจดูซ้ำซากในตอนแรก แต่ออกแบบมาอย่างตั้งใจ Go ถือว่าทุกความล้มเหลวเป็นจุดตัดสินใจ: คุณต้องเลือกว่าจะ propagate error, wrap พร้อม context, retry, fallback ไปยังค่า default หรือ log แล้วดำเนินต่อ ภาษาไม่ได้ตัดสินใจแทนคุณ

วินัยนี้ทำให้โค้ดมองเห็นทุก call ที่อาจเกิด error ได้ที่ callsite เมื่ออ่าน function ของ Go คุณจะเห็นชัดเจนว่า operation ใดที่อาจล้มเหลวได้และ caller ทำอะไรกับแต่ละกรณี — ไม่มีการ propagate แบบ implicit ไม่มีเส้นทาง exception ที่มองไม่เห็น

ค่า error เป็น interface: คู่ (type, value) มันจะเป็น nil ก็ต่อเมื่อทั้งคู่ทั้ง type และ value ไม่ได้ถูกกำหนดเท่านั้น สิ่งนี้กลายเป็นกับดักทันทีที่ function ประกาศตัวแปร pointer แบบมี type แล้วคืนค่ามันตรง ๆ แทนที่จะคืน nil เปล่า ๆ:

type MyError struct{ msg string }
func (e *MyError) Error() string { return e.msg }
func mightFail(fail bool) error {
var e *MyError // nil *MyError
if fail {
e = &MyError{msg: "boom"}
}
return e // BUG: always a non-nil error interface, even when e is nil
}

mightFail(false) คืน error ที่มี type เป็น *MyError และมี value เป็น nil — นั่นคือ interface ที่ไม่ nil ที่ถือ pointer เป็น nil อยู่ข้างใน ไม่ใช่ nil interface err == nil จะได้ false:

flowchart LR
  subgraph A["nil interface -- err == nil is true"]
    A1["type = none"]
    A2["value = none"]
  end
  subgraph B["typed nil in interface -- err == nil is false"]
    B1["type = *MyError"]
    B2["value = nil"]
  end
  subgraph C["real error -- err == nil is false"]
    C1["type = *MyError"]
    C2["value = pointer"]
  end
error คือคู่ (type, value) -- จะเป็น nil ก็ต่อเมื่อทั้งสองส่วนไม่ถูกกำหนดเลย
package main
import "fmt"
// MyError is a normal custom error type.
type MyError struct{ msg string }
func (e *MyError) Error() string { return e.msg }
// mightFail always returns the *MyError interface type — even on the
// success path, where the underlying pointer is nil.
func mightFail(fail bool) error {
var e *MyError // nil *MyError
if fail {
e = &MyError{msg: "boom"}
}
return e // BUG: always a non-nil error interface
}
func main() {
err := mightFail(false)
fmt.Println(err == nil) // false — surprising!
fmt.Printf("%v, %T\n", err, err)
// The fix: return a bare nil, not a nil pointer of a concrete type.
var clean error
if false {
clean = &MyError{msg: "boom"}
}
fmt.Println(clean == nil) // true
}

ทางแก้คือให้ถือ *MyError แบบ local เท่านั้น แล้วคืน error-typed nil เปล่า ๆ ในเส้นทางที่สำเร็จ — เหมือนที่ clean ทำด้านบน นี่คือต้นตอที่พบบ่อยที่สุดของ bug แบบ “ทำไม if err != nil ทำงานทั้งที่ไม่มีอะไรผิดพลาด?” ในโค้ด Go ไม่ว่าจะเขียนเองหรือ AI เขียน

:= จะประกาศตัวแปรใหม่ทุกครั้งที่มีชื่ออย่างน้อยหนึ่งชื่อทางซ้ายที่ยังไม่เคยมีมาก่อน — รวมถึงใน init clause ของ if ด้วย นี่เป็นสาเหตุที่พบบ่อยของการทำ error หายไปเงียบ ๆ:

func run() error {
var err error
if v, err := step(); err != nil { // BUG: := creates a new inner err
fmt.Println("inner saw:", v, err)
}
return err // always nil here, even though step() failed
}

การรันโค้ดนี้จะพิมพ์ inner saw: 0 step failed ตามด้วย run() returned: <nil> — err ตัวนอกไม่เคยถูกแตะต้องเลย เพราะ if v, err := step(); ... ทำให้เกิด shadow ด้วยตัวแปรที่ scope อยู่แค่ใน block นั้น go vet ไม่จับ shadowing แบบนี้ (analyzer ชื่อ shadow เป็นเครื่องมือแยกต่างหาก ไม่ได้เปิดใช้เป็น default) การตรวจทุก := ที่อยู่ใน if/for ข้าง ๆ err ที่มีอยู่แล้วใน scope จึงเป็นนิสัยที่ต้องรักษาไว้ด้วยตัวเอง

วินัยที่เกี่ยวข้องกันคือการจัดการทุก error แค่ครั้งเดียว เลือกอย่างใดอย่างหนึ่ง: return มัน (จะ wrap ก่อนก็ได้), log มัน, หรือ recover จากมัน — ห้ามทำมากกว่าหนึ่งอย่าง และห้ามทำน้อยกว่านั้น การทั้ง log และ return error ตัวเดียวกันจะทำให้เกิด log ซ้ำซ้อนขึ้นไปตาม call stack ส่วนการไม่ทำอะไรเลยก็ทำให้ความล้มเหลวจริงหายไปเงียบ ๆ

errors.Is เป็นเครื่องมือเอนกประสงค์สำหรับเทียบ sentinel เพราะมันรอดจากการ wrap ได้ io.EOF เป็นข้อยกเว้นที่ตั้งใจและมีเอกสารรองรับชัดเจน: contract ของ io.Reader รับประกันว่า io.EOF จะถูกคืนตรง ๆ ไม่ถูก wrap เลย ดังนั้นโค้ดเก่า ๆ จึงยังเทียบมันตรง ๆ ได้:

n, err := r.Read(buf)
if err == io.EOF {
// end of stream, not a failure
}

errors.Is(err, io.EOF) ก็ใช้ได้เช่นกันและไม่เคยผิดเลย — แนะนำให้ใช้ในโค้ดใหม่เพื่อความสม่ำเสมอกับการเช็ก sentinel อื่น ๆ ทั้งหมด — แต่ก็ไม่ต้องแปลกใจถ้าเห็น == io.EOF ในโค้ดที่ใกล้ชิดกับ stdlib มันไม่ใช่ bug

behaviour interface (เรียกอีกอย่างว่า opaque error check) ให้ caller ถามได้ว่า “error นี้ทำ X ได้ไหม?” โดยไม่ต้องรู้ concrete type ของมัน — มีประโยชน์เมื่อ error type ที่ไม่เกี่ยวข้องกันหลายตัวจาก package ต่างกันสามารถตอบคำถามเดียวกันได้ทั้งหมด:

// timeout is a behaviour interface: any error that can answer "was this a
// timeout?" satisfies it, regardless of its concrete type or package.
type timeout interface {
Timeout() bool
}
func isTimeout(err error) bool {
var t timeout
return errors.As(err, &t) && t.Timeout()
}

errors.As จะเดินไปตาม chain มองหาอะไรก็ตามที่ implement interface timeout — ไม่ใช่ type ที่เจาะจง นี่คือวิธีที่ Timeout() bool ของ net.Error ถูกออกแบบมาให้ใช้: error type ต่าง ๆ มากมายทั้งใน standard library และ third-party code implement มันไว้ caller จึงเช็กที่ behaviour ไม่ใช่ type

package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
var ErrPermission = errors.New("permission denied")
func lookup(key string) (int, error) {
switch key {
case "age":
return 30, nil
case "secret":
return 0, ErrPermission
default:
return 0, fmt.Errorf("lookup %q: %w", key, ErrNotFound)
}
}
func main() {
v, err := lookup("age")
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("age:", v)
_, err = lookup("x")
if err != nil {
fmt.Println("error:", err)
fmt.Println("is ErrNotFound:", errors.Is(err, ErrNotFound))
}
_, err = lookup("secret")
if err != nil {
fmt.Println("error:", err)
fmt.Println("is ErrPermission:", errors.Is(err, ErrPermission))
}
}

เมื่อโค้ดที่ AI generate return หรือเช็ก error ให้มองหาสิ่งเหล่านี้ก่อนเชื่อมัน:

  • function ที่ประกาศตัวแปร pointer แบบมี type (var e *MyError) เคย return e ตรง ๆ ในเส้นทางที่สำเร็จหรือเปล่า แทนที่จะ return nil เปล่า ๆ? นั่นคือกับดัก typed-nil ด้านบน
  • การเทียบ sentinel ใช้ == ทั้งที่ error อาจผ่าน fmt.Errorf("...: %w", err) มาจากที่ไหนสักแห่งใน call chain ก่อนหน้าหรือเปล่า? == จะพังทันทีที่มี wrapper เข้ามา ส่วน errors.Is ไม่พัง
  • err ถูกประกาศซ้ำด้วย := ข้างใน if/for ที่มี err อยู่ใน scope อยู่แล้วหรือเปล่า? เช็กดูว่า err ตัวนอกถูกอ่านค่าจริงในภายหลังหรือไม่
  • ทุก error ถูกจัดการแค่ครั้งเดียวหรือเปล่า? สังเกต log.Println(err) ที่ตามด้วย return err ทันทีใน function เดียวกัน — นั่นคือการ log ความล้มเหลวเดียวกันซ้ำสองครั้งขณะที่มัน propagate ขึ้นไปตาม stack
  • การเช็ก behaviour ใช้ type assertion ตรง ๆ (err.(timeout)) แทนที่จะใช้ errors.As(err, &t) หรือเปล่า? type assertion จะพลาด interface นั้นทันทีที่ error ถูก wrap แม้แต่ครั้งเดียว

🐛 หา bug ในโค้ดนี้

var ErrNotFound = errors.New("not found")
func find(id string) error {
return fmt.Errorf("find %s: %w", id, ErrNotFound)
}
func main() {
err := find("42")
if err == ErrNotFound {
fmt.Println("not found")
}
}
เฉลย

find wrap ErrNotFound ด้วย %w ทำให้ err เป็น *fmt.wrapError ไม่ใช่ ErrNotFound เอง err == ErrNotFound เทียบ wrapper นั้นกับ sentinel ด้วย identity แล้วได้ false — โค้ดใน if ไม่ทำงานเลย ทั้งที่ ErrNotFound อยู่ใน chain จริง ๆ ตรวจสอบแล้วบน 1.26.5: พิมพ์ err == ErrNotFound ได้ false, errors.Is(err, ErrNotFound) ได้ true ทางแก้คือใช้ errors.Is(err, ErrNotFound) ซึ่งเดินไปตาม chain แทนที่จะเทียบแค่ค่าชั้นนอกสุด

สิ่งที่ได้ประโยชน์ต้นทุน
error interface (1 method)implement ง่าย, compose ได้ไม่มี stack trace โดยตรง
errors.Is()match sentinel ใน chain ลึกแค่ไหนก็ได้ต้อง wrap error ด้วย %w ให้ถูกต้องก่อน
errors.As()extract concrete error type พร้อม fieldtarget ต้องเป็น pointer ไปยัง type ของ error (&ve เมื่อ ve *VErr) ถ้าไม่ใช่ panic ตอน runtime
sentinel errorsเปรียบเทียบง่าย, self-documentingเป็น public API — refactor ยากถ้า name ต้องเปลี่ยน
  • return nil error หมายถึง function สำเร็จเสมอ — ถ้า caller ละเว้น error ด้วย _ โดยไม่ตั้งใจ error ถูก ignore โดยสมบูรณ์
  • errors.New() และ fmt.Errorf() เหมือนกัน — fmt.Errorf กับ %w สามารถ wrap error chain ได้, errors.New ไม่สามารถ
  • error message ควร capitalize — Go convention: error message ไม่ capitalize และไม่มี punctuation ท้าย เช่น "not found" ไม่ใช่ "Not found."
  • panic ดีกว่า return error สำหรับ critical error — ใช้ panic เฉพาะสำหรับ programming error ที่ should not happen, ไม่ใช่ expected failure

💡 ตัวอย่างจากของจริง

net/http: http.ErrServerClosed เป็น sentinel error สำหรับ graceful shutdown — caller ตรวจด้วย errors.Is(err, http.ErrServerClosed)

database/sql: sql.ErrNoRows เป็น sentinel ที่ทุก Go database driver ใช้ร่วมกัน

การคืนค่า nil เป็น error หมายความว่าอะไร?
โค้ดนี้พิมพ์อะไร?
type MyError struct{ msg string }
func (e *MyError) Error() string { return e.msg }

func mightFail(fail bool) error {
    var e *MyError
    if fail {
        e = &MyError{msg: "boom"}
    }
    return e
}

func main() {
    err := mightFail(false)
    fmt.Println(err == nil)
}
สมมติ step() คืน error ที่ไม่ใช่ nil, run() จะ return อะไร?
func run() error {
    var err error
    if v, err := step(); err != nil {
        fmt.Println("inner saw:", v, err)
    }
    return err
}
sentinel error ประกาศด้วยรูปแบบใด?
ควรใช้ fmt.Errorf แทน errors.New เมื่อใด?