บทเรียน 12 · Day 4 · Error Handling

Error Architecture: error คือข้อมูล ไม่ใช่ข้อความ

~16 นาที · ต้องมี: บทเรียน 05, บทเรียน 10

บทเรียน 05 สอนว่า Go คืน error เป็นค่า บทนี้ไปต่ออีกขั้น: error ไม่ได้มีไว้แค่ print — มันคือข้อมูลที่ควบคุมการทำงานของระบบ ตอบ 404 หรือ 500 · retry หรือยอมแพ้ · แสดงข้อความอะไรให้ผู้ใช้ ทั้งหมดนี้ตัดสินจาก error ที่ลอยขึ้นมา

1 · error เป็นแค่ interface ที่มี method เดียว

// นิยามจริงใน Go — built-in ไม่ต้อง import อะไร
type error interface {
    Error() string
}

แค่นั้นจริง ๆ อะไรก็ตามที่มี method Error() string คือ error ได้ทันที — ตรงตาม duck typing จาก บทเรียน 10 แปลว่าคุณสร้าง error ที่พก field อะไรก็ได้ ติดไปด้วย (field = ช่องข้อมูลใน struct เหมือนช่องกรอกในแบบฟอร์ม)

type ValidationError struct {
    Field   string
    Message string
}

func (e ValidationError) Error() string {
    return fmt.Sprintf("%s: %s", e.Field, e.Message)
}

func ValidateAge(age int) error {
    if age < 0 {
        return ValidationError{
            Field:   "age",
            Message: "must not be negative",
        }
    }
    return nil
}

ตอนนี้ผู้เรียกได้มากกว่าข้อความ — ได้ Field ไปใช้ highlight ช่องกรอกที่ผิดในหน้าเว็บได้

2 · errors.New() ต่างจาก fmt.Errorf() ยังไง

errors.New()fmt.Errorf()
ใช้เมื่อข้อความคงที่ ไม่มีตัวแปรต้องแทรกข้อมูล หรือห่อ error เดิม
ตัวอย่างerrors.New("insufficient balance")fmt.Errorf("read %q: %w", name, err)
ห่อ error ได้ไหม❌✅ ด้วย %w
เทียบด้วย ==✅ (จึงทำ sentinel ได้)❌ สร้างใหม่ทุกครั้ง
ระวังตอนพิมพ์ตามสไลด์

สไลด์หน้านี้แสดง func Withdraw(balance, amount int) error แล้วขึ้นบรรทัดใหม่ค่อยเปิด { — ถ้าพิมพ์ตามนั้นตรง ๆ จะ compile ไม่ผ่าน ผมรันแล้วได้ syntax error: unexpected semicolon or newline before { เพราะ Go เติม semicolon ท้ายบรรทัดให้อัตโนมัติ ปีกกาเปิดต้องอยู่บรรทัดเดียวกับ func เสมอ (น่าจะเป็นแค่การตัดบรรทัดตอนทำสไลด์ แต่ถ้าลอกไปจะงงว่าพังเพราะอะไร)

errors.New() — ข้อความคงที่

func Withdraw(balance, amount int) error {
    if amount > balance {
        return errors.New(
            "insufficient balance",
        )
    }
    return nil
}

fmt.Errorf() — เติมข้อมูล/ห่อ

func LoadConfig(filename string) error {
    data, err := os.ReadFile(filename)
    if err != nil {
        return fmt.Errorf(
            "read config %q: %w", filename, err,
        )
    }
    _ = data
    return nil
}

3 · Sentinel Error — error ที่มีชื่อให้เทียบได้

ปัญหาที่หัวข้อนี้แก้: ชั้นบนอยากรู้ว่า “พังเพราะอะไร” จะได้ตัดสินใจถูก แต่ถ้าไปดูจากข้อความของ error วันไหนมีคนแก้ข้อความ โค้ดก็พังตาม
ทางออกคือประกาศตัว error ไว้ล่วงหน้าเป็นตัวแปรที่มีชื่อ แล้วเทียบที่ตัวมันเอง ไม่ใช่ที่ข้อความ · ตัวแปรแบบนี้เรียกว่า sentinel

package user

// นิยมประกาศระดับ package และตั้งชื่อขึ้นต้นด้วย Err
var (
    ErrUserNotFound     = errors.New("user not found")
    ErrEmailAlreadyUsed = errors.New("email already used")
    ErrPermissionDenied = errors.New("permission denied")
)

ประโยชน์คือผู้เรียกตัดสินใจจาก error ได้ โดยไม่ต้องเทียบข้อความ:

if err == user.ErrUserNotFound {       // ใช้ได้ แต่เปราะมาก
if errors.Is(err, user.ErrUserNotFound) {  // ✓ ใช้อันนี้เสมอ

ทำไมต้อง errors.Is ทั้งที่ == ก็ได้: == เทียบได้เฉพาะตอน error ตัวนั้นลอยขึ้นมาถึงคุณตรง ๆ แต่ในระบบจริงชั้นกลางมักห่อมันด้วย fmt.Errorf("...: %w", err) ก่อน (หัวข้อ 4) — พอถูกห่อแล้ว err คือ error ตัวใหม่ == จึงได้ false ทันที ส่วน errors.Is จะแกะห่อไล่ลงไปเรื่อย ๆ จนเจอต้นตอ
เขียน errors.Is ไว้ตั้งแต่แรกดีกว่า เพราะวันที่มีคนเพิ่มการห่อเข้ามา โค้ดคุณจะไม่พังเงียบ ๆ

if errors.Is(err, user.ErrUserNotFound) {
    // ตอบ 404
} else if errors.Is(err, user.ErrPermissionDenied) {
    // ตอบ 403
}
ห้ามเด็ดขาด
if err.Error() == "user not found" {   // ✗ อย่าทำ

ข้อความ error คือสิ่งที่มนุษย์อ่าน ไม่ใช่ API วันไหนมีคนแก้เป็น "User not found" (ตัว U ใหญ่) หรือใส่ id ต่อท้าย โค้ดคุณจะพังเงียบ ๆ โดยไม่มี compile error เตือน

🏷 เทียบให้เห็นภาพ — บาร์โค้ดกับป้ายชื่อสินค้า

ตอนจ่ายเงินที่ซูเปอร์ฯ แคชเชียร์ยิงบาร์โค้ด ไม่ได้อ่านชื่อบนป้ายแล้วพิมพ์เอง
เพราะป้ายเปลี่ยนเมื่อไหร่ก็ได้ — วันนี้ “นมสด 200 มล.” พรุ่งนี้พิมพ์ใหม่เป็น “นมโคสด 200ml” ถ้าระบบผูกกับข้อความบนป้าย วันที่ป้ายเปลี่ยนก็คิดเงินผิดทันที ส่วนบาร์โค้ดยังเป็นตัวเดิม

· ป้ายชื่อสินค้า = err.Error() — มีไว้ให้คนอ่าน
· บาร์โค้ด = ErrUserNotFound — มีไว้ให้โค้ดเทียบ
· การยิงบาร์โค้ด = errors.Is(err, ErrUserNotFound)

ที่ไม่เหมือนกัน: บาร์โค้ดเป็นตัวเลข สินค้าคนละชิ้นแต่รุ่นเดียวกันใช้เลขร่วมกันได้ แต่ sentinel เทียบกันที่ตัวมันเอง ตัวเดียวในหน่วยความจำ — ต่อให้ประกาศ errors.New("user not found") ขึ้นใหม่อีกตัวที่ข้อความเหมือนกันเป๊ะ errors.Is ก็ยังบอกว่าคนละตัวกัน
(ข้อยกเว้นที่เจอไม่บ่อย แต่บอกไว้ก่อนจะได้ไม่ตกใจ: error บางชนิดเขียน method Is(error) bool ของตัวเองไว้ เพื่อยอมให้ match ข้ามตัวได้ — เช่น errors.Is(syscall.ENOENT, fs.ErrNotExist) ได้ true ทั้งที่สองตัวนี้คนละชนิดคนละก้อนกันเลย)

🗣 ศัพท์ที่เพิ่งเรียนไป
sentinel error
“บาร์โค้ด” ของ error — error ที่ประกาศไว้ล่วงหน้าเป็นตัวแปรระดับ package เพื่อให้คนอื่นเทียบได้ · เวลาถามให้พูดว่า “package นี้มี sentinel error ตัวไหนให้เช็คบ้าง” จะได้คำตอบตรงกว่าถามว่า “มัน error อะไรได้บ้าง”
errors.Is
ฟังก์ชันที่ถามว่า “error ก้อนนี้ใช่ตัวนั้นไหม” — และมันไล่ดูให้ทั้งโซ่ ไม่ใช่ดูแค่ชั้นนอกสุด (ยังไม่เห็นภาพว่า “โซ่” คืออะไรก็ไม่เป็นไร หัวข้อ 4 อธิบายให้) · ประโยคที่ใช้ได้จริง: “ตรงนี้เช็คด้วย errors.Is นะ อย่าใช้ ==”
เปราะ (brittle)
คำที่ reviewer ใช้ตอนติโค้ดที่พังง่ายเวลามีคนแก้อย่างอื่นที่ดูไม่น่าเกี่ยวกัน · การเทียบ error ด้วยข้อความคือตัวอย่างคลาสสิกของคำนี้

4 · Error Chain — ห่อ error โดยไม่ทำต้นตอหาย

ระบบจริงมีหลายชั้น แต่ละชั้นควรเติมบริบทของตัวเองลงไป โดยไม่กลบข้อมูลของชั้นล่าง
ชั้นที่พูดถึงในโค้ดข้างล่างคือ repository (ชั้นที่คุยกับฐานข้อมูล) → service (ชั้นที่ตัดสินใจตามกฎของงาน) → handler (ชั้นที่รับคำขอจากผู้ใช้และตอบกลับ)

📦 เทียบให้เห็นภาพ — พัสดุที่ห่อทับกันหลายชั้น

สั่งของออนไลน์ กล่องที่มาถึงบ้านมักมีกล่องเล็กซ้อนอยู่ข้างใน — ร้านห่อกล่องของโรงงานอีกที แล้วขนส่งแปะใบปะหน้าทับอีกชั้น
ใบปะหน้าชั้นนอกบอกว่า “ผ่านมือใครมาบ้าง” แต่ของจริงยังอยู่ข้างในครบ

· %w = ห่อทับ เก็บกล่องเดิมไว้ข้างในจริง ๆ
· %v = ถ่ายรูปกล่องเดิมมาแปะหน้ากล่องใหม่ แล้วทิ้งของเดิม — หน้ากล่องอ่านเหมือนกันเป๊ะ แต่แกะเข้าไปไม่มีอะไร นี่คือเหตุผลที่บั๊กตัวนี้หายากมาก

ที่ไม่เหมือนกัน: พัสดุจริงต้องฉีกกล่องถึงจะรู้ว่าข้างในมีอะไร และฉีกแล้วห่อคืนไม่ได้ แต่ errors.Is/errors.As มองทะลุได้โดยไม่ทำลายอะไร — ตรวจเสร็จ error ก้อนเดิมยังส่งต่อขึ้นไปให้ชั้นบนตรวจซ้ำได้อีก

// Repository:
func (r UserRepository) Find(id string) (User, error) {
    user, err := queryDatabase(id)
    if err != nil {
        return User{}, fmt.Errorf("query user: %w", err)
    }
    return user, nil
}

// Service: เติมบริบทอีกชั้น
func (s UserService) GetProfile(id string) (User, error) {
    user, err := s.repository.Find(id)
    if err != nil {
        return User{}, fmt.Errorf("get profile %s: %w", id, err)
    }
    return user, nil
}
ข้อความสุดท้ายที่ handler เห็น:

  get profile U-101: query user: user not found
  └──── Service ────┘└─ Repository ─┘└─ ต้นตอ ─┘

แต่ละชั้นเติมคำนำหน้า ต้นตอยังอยู่ท้ายสุดเสมอ
และ errors.Is() ยังมองทะลุถึงต้นตอได้
err := fmt.Errorf("query user: %v", ErrUserNotFound)
                              //     ↑ %v ไม่ใช่ %w

fmt.Println(errors.Is(err, ErrUserNotFound))

พิมพ์ true หรือ false?

false — ผมรันทดสอบแล้ว

%v แค่แปลง error เป็นข้อความ แล้วโยนตัว error เดิมทิ้ง — ข้อความสุดท้ายอ่านเหมือนกันเป๊ะ แต่โซ่ขาด ส่วน %w เก็บ error เดิมไว้ข้างในให้ errors.Is ตามไปเจอ
นี่คือบั๊กที่หาไม่เจอที่สุดในเรื่อง error เพราะ log สวยงามทุกอย่าง แต่ errors.Is คืน false เงียบ ๆ

🗣 ศัพท์ที่เพิ่งเรียนไป
wrap / ห่อ error
เอา error เดิมใส่ไว้ข้างใน error ใหม่ · ประโยคที่ใช้ได้จริง: “ตรงนี้ wrap ด้วย %w หรือยัง” — สั้น ๆ แค่นี้ senior เข้าใจทันทีว่าถามอะไร
error chain
“กล่องซ้อนกล่อง” ทั้งสาย ตั้งแต่ชั้นนอกสุดลงไปถึงต้นตอ · พูดว่า “ต้นตอใน error chain คืออะไร” จะสื่อกว่าถามว่า “มัน error อะไรกันแน่”
unwrap
การแกะออกทีละชั้น · errors.Is/As แกะให้อัตโนมัติ และโดยปกติจะแกะต่อลงไปได้เมื่อชั้นนั้นมี method Unwrap() (หัวข้อ 6)
context ของ error
ข้อมูลที่แต่ละชั้นเติมลงไปว่า “ตอนพังกำลังทำอะไรอยู่” เช่น get profile U-101: · โดนติว่า “error นี้ไม่มี context” = อ่านแล้วไม่รู้ว่าพังตอนไหน ไม่ได้แปลว่าข้อความสั้นไป
ระวัง — คนละเรื่องกับ context.Context ที่ใช้สั่ง cancel/timeout นะ ชื่อพ้องกันเฉย ๆ

5 · errors.Is กับ errors.As — คนละงานกัน

errors.Is(err, target)errors.As(err, &target)
ถามว่า"ในโซ่นี้มี error ตัวนี้ ไหม""ในโซ่นี้มี error ชนิดนี้ ไหม"
คู่กับsentinel (errors.New)custom error struct
ได้อะไรกลับbool อย่างเดียวbool + ตัว error จริงพร้อม field
อาร์กิวเมนต์ 2ตัว errorpointer ไปยังตัวแปร (ต้องมี &)
🧳 เทียบให้เห็นภาพ — ด่านตรวจกระเป๋าที่สนามบิน

กระเป๋าใบเดียวกัน เจ้าหน้าที่ถามได้สองแบบ
· “มีไฟแช็กไหม” — ตอบ มี/ไม่มี ก็จบ ไม่ต้องเอาออกมา → นี่คือ errors.Is
· “มีของเหลวไหม ถ้ามีขอเอาออกมาวางในถาด” — ต้องหยิบออกมาก่อน ถึงจะอ่านฉลากได้ว่ากี่มิลลิลิตร → นี่คือ errors.As

และนี่คือเหตุผลที่ errors.As ต้องมี &: คุณต้องยื่น “ถาด” ให้เขาวางของ · var validationErr ValidationError = ถาดเปล่า · &validationErr = การยื่นถาดใบนั้นให้เขา
ถ้าไม่ยื่นถาด เจ้าหน้าที่ไม่ทำงานต่อให้เลย ตะโกนหยุดทันที — ในโค้ดจริงคือเขียน errors.As(err, validationErr) ตกเครื่องหมาย & ไป อันนี้ คอมไพล์ผ่านนะ แต่พอรันแล้วโปรแกรม panic ว่า errors: target must be a non-nil pointer — ไม่ได้คืน false และไม่ได้ลดชั้นไปทำงานแบบ errors.Is ให้
เคสนี้คนละเรื่องกับกล่อง “value กับ pointer ใช้แทนกันไม่ได้” ที่อยู่ถัดลงไป: อันนั้นคือยื่นถาดผิดชนิด ซึ่งได้ false เงียบ ๆ จริง ส่วนอันนี้คือไม่ยื่นถาดเลย โปรแกรมหยุดกลางคัน

ที่ไม่เหมือนกัน: เจ้าหน้าที่รื้อกระเป๋าเองได้ทุกซอก แต่ errors.As เข้าไปชั้นในได้ก็ต่อเมื่อแต่ละชั้นยอมเปิดให้ — โดยปกติคือชั้นนั้นต้องมี Unwrap() ถ้าชั้นไหนไม่เปิด การค้นจบตรงนั้นทันที

// errors.Is — เช็คว่าใช่ตัวนั้นไหม
err := FindUser("U-101")
if errors.Is(err, ErrUserNotFound) {
    fmt.Println("show 404 page")
}
// errors.As — ดึงตัวจริงออกมาใช้ field
// ⚠️ ชนิดของตัวแปรต้องตรงกับที่ "คืนมาจริง" — หัวข้อ 1 คืน ValidationError{} (ค่า)
var validationErr ValidationError

err := ValidateAge(-1)
if errors.As(err, &validationErr) {
    fmt.Println("invalid field:", validationErr.Field)
    //                              ↑ ได้ field มาใช้ต่อ
}
value กับ pointer ใช้แทนกันไม่ได้ใน errors.As

ถ้าโค้ด return ValidationError{...} (ค่า) แต่รับด้วย var e *ValidationError — ผมรันแล้วได้ false ไม่มี error ไม่มี panic (โปรแกรมหยุดกลางคัน) เงียบสนิท เพราะ errors.As ถามว่า "ในโซ่มีตัวที่เป็น ชนิดนี้เป๊ะ ๆ ไหม" และ ValidationError กับ *ValidationError คือคนละชนิดกัน (กฎเดียวกับ บทเรียน 09)
วิธีกัน: ดูว่าโค้ดคุณ return อะไร แล้วประกาศตัวรับให้ตรงแบบนั้น — คืน &AppError{} ก็รับด้วย var e *AppError คืน ValidationError{} ก็รับด้วย var e ValidationError

จำง่าย ๆ: Is = "ใช่อันนี้ไหม" ตอบได้แค่ใช่/ไม่ใช่ · As = "เป็นชนิดนี้ไหม ถ้าใช่ขอตัวจริงด้วย"
สังเกตว่า errors.As ก็คือ type assertion จาก บทเรียน 11 ที่เดินตามโซ่ให้ด้วยนั่นเอง

🗣 ศัพท์ที่เพิ่งเรียนไป
errors.As
“ในโซ่มี error ชนิดนี้ ไหม ถ้ามีขอตัวจริงด้วย” · ประโยคที่ใช้ได้จริง: “ตรงนี้ต้องใช้ errors.As เพราะผมอยากได้ field ข้างใน ไม่ใช่แค่รู้ว่าใช่หรือเปล่า”
target
“ถาด” ที่เรายื่นให้ — ตัวแปรที่ errors.As จะเอาค่าไปใส่ให้ · เอกสาร Go เรียกอาร์กิวเมนต์ตัวที่สองว่า target ตรง ๆ
custom error type
error ที่เราสร้างชนิดขึ้นมาเอง — ส่วนใหญ่เป็น struct เพื่อพก field เพิ่ม (เช่น ValidationError) แต่จริง ๆ จะเป็นชนิดอะไรก็ได้ที่มี method Error() string เช่น type ErrCode int · ตรงข้ามกับ sentinel ที่มีแค่ข้อความ · ถามว่า “ตัวนี้เป็น sentinel หรือ custom error type” จะได้คำตอบตรงกว่ามาก

6 · Custom Error Struct — แบบที่ใช้จริงในโปรดักชัน

หัวข้อนี้เอาทุกอย่างมารวมกัน: แทนที่จะคืน error ที่มีแค่ข้อความ เราคืน struct ที่พกทุกอย่างที่ชั้นบนต้องใช้ตัดสินใจไปด้วย (โปรดักชัน = ระบบตัวจริงที่ผู้ใช้ใช้อยู่ ไม่ใช่เครื่องเราตอนพัฒนา)

type AppError struct {
    Op         string   // เกิดที่ไหน — "UserService.GetUser"
    Code       string   // รหัสให้เครื่องอ่าน — "USER_NOT_FOUND"
    HTTPStatus int      // ตอบ client ยังไง — 404
    Message    string   // ข้อความให้ "ผู้ใช้" อ่าน
    Err        error    // error เดิมที่ห่อไว้
}

func (e *AppError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("%s: %s: %v", e.Op, e.Message, e.Err)
    }
    return fmt.Sprintf("%s: %s", e.Op, e.Message)
}

// ⚠️ ขาดอันนี้ไม่ได้ — ไม่งั้น errors.Is/As มองทะลุไม่ได้
func (e *AppError) Unwrap() error {
    return e.Err
}

ใช้ที่ชั้น service

func GetUser(id string) (User, error) {
    user, err := repository.FindByID(id)
    if err != nil {
        if errors.Is(err, ErrUserNotFound) {
            return User{}, &AppError{
                Op:         "UserService.GetUser",
                Code:       "USER_NOT_FOUND",
                HTTPStatus: http.StatusNotFound,
                Message:    "ไม่พบผู้ใช้งาน",
                Err:        err,
            }
        }
        return User{}, &AppError{
            Op:         "UserService.GetUser",
            Code:       "INTERNAL_ERROR",
            HTTPStatus: http.StatusInternalServerError,
            Message:    "ไม่สามารถโหลดข้อมูลผู้ใช้ได้",
            Err:        err,
        }
    }
    return user, nil
}

ใช้ที่ชั้น handler

user, err := service.GetUser(id)
if err != nil {
    var appErr *AppError

    if errors.As(err, &appErr) {
        http.Error(w, appErr.Message, appErr.HTTPStatus)
        return
    }

    // error ที่ไม่รู้จัก — อย่าเผยรายละเอียดออกไป
    http.Error(w, "internal server error", http.StatusInternalServerError)
    return
}

ผลรันจริงของโครงนี้:

err.Error()  = UserService.GetUser: ไม่พบผู้ใช้งาน: query user U-101: user not found
errors.Is(err, ErrUserNotFound)  = true      ← ทะลุถึงต้นตอ
errors.As(err, &appErr)          = true
appErr.Code = USER_NOT_FOUND | HTTPStatus = 404
สิ่งที่โครงนี้แก้ให้

แยกข้อความสำหรับผู้ใช้ ออกจากข้อมูลสำหรับ developer — ผู้ใช้เห็น "ไม่พบผู้ใช้งาน" ส่วน log เก็บโซ่เต็ม ๆ ว่าพังตรงไหน และ handler ตัดสิน HTTP status ได้โดยไม่ต้องรู้จัก database เลยสักนิด

7 · กับดักร้ายแรง: nil ที่ไม่ nil

func doWork() error {
    var e *AppError = nil
    return e            // คืน pointer ที่เป็น nil
}

if doWork() == nil {
    fmt.Println("ไม่มี error")
} else {
    fmt.Println("มี error!")
}

พิมพ์อะไรออกมา?

มี error! — ผมรันทดสอบแล้ว doWork() == nil ได้ false

ย้อนกลับไปดูรูปกล่องใน บทเรียน 11 — interface เก็บ สองช่อง:

┌──────────────┬────────────┐
│   *AppError  │    nil     │   ← ช่อง type ไม่ว่าง!
└──────────────┴────────────┘
interface จะ == nil ก็ต่อเมื่อ ทั้งสองช่อง ว่าง

ทางแก้: อย่าประกาศตัวแปรเป็น *AppError แล้วคืนมันไปตรง ๆ ให้ return nil ตรง ๆ ในเส้นทางที่สำเร็จ:

func doWork() error {
    if somethingWrong {
        return &AppError{...}
    }
    return nil          // ✓ nil ของแท้
}
✉️ เทียบให้เห็นภาพ — ซองเปล่ากับการไม่ส่งซองมาเลย

ตกลงกับเพื่อนไว้ว่า “ถ้ามีปัญหาให้ส่งจดหมายมา ถ้าไม่มีก็ไม่ต้องส่ง”
วันหนึ่งเปิดตู้จดหมายแล้วเจอซอง ก็สรุปทันทีว่า “มีปัญหาแล้ว” ทั้งที่แกะออกมาข้างในไม่มีกระดาษสักแผ่น

Go มองแบบเดียวกันเป๊ะ ๆ · error เป็น interface ที่มีสองช่อง: ช่อง “ซองแบบไหน” (type) กับ ช่อง “ข้างในมีอะไร” (value)
การคืน var e *AppError = nil ออกไป = ส่งซองเปล่ามา — ช่อง type มีคำว่า *AppError อยู่ ไม่ว่าง ผู้เรียกจึงเห็นว่า err != nil

ที่ไม่เหมือนกัน: ซองเปล่ายังแกะดูได้อย่างปลอดภัย แต่ error ก้อนนี้ถ้าเผลอเรียก err.Error() แล้วข้างในไปแตะ field โปรแกรมจะหยุดกลางคัน (panic) ทันที — ไม่ใช่แค่ตอบผิด แต่ล้มทั้งคำขอนั้นไปเลย

🗣 ศัพท์ที่เพิ่งเรียนไป
typed nil (nil ที่ไม่ nil)
“ซองเปล่าที่ยังนับว่ามีซอง” · ถ้าเจออาการนี้ ให้พูดกับ senior หรือพิมพ์ถาม AI ว่า “น่าจะโดน typed nil ตอนคืน *AppError เป็น error” — คำนี้คำเดียวพาไปหาคำตอบได้เลย
interface value
ค่าที่อยู่ใน interface ซึ่งเก็บสองช่องคู่กันเสมอ (type, value) · จะ == nil ก็ต่อเมื่อว่างทั้งสองช่อง
panic
โปรแกรมหยุดกลางคันเพราะเจอสิ่งที่ไปต่อไม่ได้ · ต่างจาก error ตรงที่ error คือค่าที่คืนมาให้เราจัดการ ส่วน panic คือล้มไปเลย ถ้าไม่มีใครรับไว้
nil pointer dereference
สาเหตุของ panic ที่เจอบ่อยที่สุด — ไปหยิบของจากที่อยู่ที่ไม่มีอะไรอยู่ · ใน log จะเห็นเป็น invalid memory address or nil pointer dereference

8 · ทดสอบความจำ

1. ใช้ %v แทน %w ใน fmt.Errorf มีผลยังไง?

นี่คือกับดักที่อันตรายที่สุด เพราะ log ออกมาสวยงามเหมือนเดิมทุกอย่าง แต่ errors.Is คืน false เงียบ ๆ → handler ตอบ 500 แทนที่จะเป็น 404 %w เท่านั้นที่เก็บ error เดิมไว้ในโซ่

2. custom error struct ขาด method Unwrap() จะเป็นยังไง?

errors.Is/As เดินตามโซ่โดยเรียก Unwrap() ไปเรื่อย ๆ — ไม่มี Unwrap() = โซ่จบตรงนั้น field Err ยังอยู่ครบ แต่ไม่มีใครตามไปดูได้

3. อยากได้ Field จาก ValidationError ในโซ่ ใช้อะไร?

errors.Is ตอบได้แค่ bool เอา field มาไม่ได้ · errors.As(err, &validationErr) เดินตามโซ่ให้แล้ว ยัดตัวจริงลงในตัวแปรที่คุณส่ง pointer ไป → ใช้ validationErr.Field ได้เลย · อย่าลืม &

4. คืน var e *AppError = nil จากฟังก์ชันที่ประกาศคืน error?

interface เก็บ type + value — ช่อง type มี *AppError อยู่ ไม่ว่าง จึงไม่เท่ากับ nil ทั้งที่ค่าข้างในเป็น nil วิธีกัน: เขียน return nil ตรง ๆ ในเส้นทางที่สำเร็จ อย่าคืนตัวแปรที่เป็น pointer type

9 · ลงมือทำ

ภารกิจ: ระบบค้นหาสินค้าและจัดการ Error Chain (workshop จากสไลด์)

  1. ประกาศ sentinel: var ErrProductNotFound = errors.New("product not found")
  2. ชั้น repository: findProduct(code string) (Product, error) — ถ้าไม่พบให้คืน ErrProductNotFound
  3. ชั้น service: getProduct(code string) (Product, error) — ห่อด้วย fmt.Errorf("find product %q: %w", code, err) แล้วครอบด้วย &AppError{Code: "PRODUCT_NOT_FOUND", Message: "ไม่พบสินค้าที่ต้องการ", Err: ...}
    ภารกิจนี้ใช้ AppError แบบย่อ (แค่ 3 field) และ Error() คืน fmt.Sprintf("%s: %v", e.Message, e.Err) — เพิ่ม Op/HTTPStatus ทีหลังได้
  4. อย่าลืมเขียน func (e *AppError) Unwrap() error
  5. ชั้น main: ตรวจด้วย errors.Is() และ errors.As() ให้ได้ผลลัพธ์:
    พบ ErrProductNotFound ใน Error Chain
    code: PRODUCT_NOT_FOUND
    message: ไม่พบสินค้าที่ต้องการ
    error: ไม่พบสินค้าที่ต้องการ: find product "P999": product not found
  6. ห้ามตรวจ error ด้วยการเทียบข้อความ string — กฎจากสไลด์
  7. ท้าทาย 1: เปลี่ยน %w เป็น %v ในข้อ 3 แล้วรันใหม่ — ข้อความเปลี่ยนไหม? errors.Is เปลี่ยนไหม? นี่คือการพิสูจน์กับดักในหัวข้อ 4 ด้วยมือตัวเอง
  8. ท้าทาย 2: ลบ Unwrap() ออก แล้วดูว่า errors.Is กับ errors.As อันไหนพัง
    สปอยล์ผลรันจริง: errors.Is → false แต่ errors.As → true เหมือนเดิม ทำไม As ถึงยังรอด? คำใบ้: err ตัวนอกสุดคือ *AppError อยู่แล้ว ไม่ต้องเดินโซ่ก็เจอ

อ่านต่อจากต้นทาง

แหล่งหลักที่แนะนำ: Working with Errors in Go 1.13 — เขียนโดยทีม Go ตอนที่เพิ่ม %w/Is/As เข้ามา จึงอธิบายว่าแต่ละอันเกิดมาแก้ปัญหาอะไร ไม่ใช่แค่ syntax ประมาณ 15 นาที และเป็นแหล่งที่ควรอ่านก่อนออกแบบ error ของโปรเจกต์จริง

💬 ติดตรงไหนถามได้เลย — โครง AppError มี 5 field ซึ่งอาจดูเยอะเกินไปสำหรับโปรเจกต์เล็ก ถ้าอยากคุยว่าโปรเจกต์ของคุณควรมี field ไหนบ้าง บอกมาได้ เป็นการตัดสินใจที่ขึ้นกับว่าใครเป็นคนอ่าน error ของคุณ

อ้างอิง