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

error เป็น​ค่า ไม่ใช่​ข้อ​ยกเว้น

บท​ที่ 5 ทำให้ orderctl คิด​ส่วนลด​ได้ แต่​มัน​ยัง​เชื่อ​ทุก​อย่าง​ที่​อ่าน​เข้า​มา

บท​นี้​เติม​ชั้น​ที่​ขาด — order.Validate ที่​ตรวจ​ออ​เด​อร์​แล้ว​บอก​ว่า ผิด​กี่​ข้อ ข้อ​ไหน​บ้าง ไม่ใช่​หยุด​ที่​ข้อ​แรก​แล้ว​เงียบ

📦 orderctl ได้​อะไร​เพิ่ม​ใน​บท​นี้
  • ErrNoLines และ ErrBadTable — ค่า error ระดับ package ที่​ผู้​เรียก​เทียบ​ได้
  • *order.FieldError — error ชนิด​ของ​ตัว​เอง ที่​พก field, ค่าที่​ผิด และ​เหตุผล​มา​ด้วย
  • Order.Validate ที่​คืน error ตัว​เดียว​ซึ่ง​ห่อ​ทุก​ข้อ​ที่​ผิด​ไว้​ข้าง​ใน
  • exit code ของ​โปรแกรม​บรรทัด​คำ​สั่ง ที่​ผูก​กับ​ชนิด​ของ​ความ​ผิด​พลาด

ทีม Go ปิด​ประตู​เรื่อง​ไวยากรณ์​ใหม่​สำหรับ error ไป​แล้ว if err != nil จึง​เป็น​รูป​สุดท้าย

ทุก​ภาษา​ที่​คุณ​เคย​ใช้​มี​ทาง​ลัด​ให้​ความ​ผิด​พลาด​เด้ง​ขึ้น​ไป​หา​ผู้​เรียก​เอง Go ไม่มี​ทาง​นั้น​เลย

error เป็น​แค่ interface ที่​มี method เดียว ฟังก์ชัน​คืน​มัน​กลับ​มา​เหมือน​คืนตัว​เลข และ​ผู้​เรียก​ต้อง​เขียน​ว่า​จะ​ทำ​อะไร​กับ​มัน ณ จุด​ที่​ได้​รับ

// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์
type error interface {
Error() string
}

หลาย​ปี​ที่​ผ่าน​มา​มี​ข้อ​เสนอ​ให้​เพิ่ม​ไวยากรณ์​ย่อ เช่น try หรือ​ตัว​ดำเนิน​การ ?

Robert Griesemer เขียน​ไว้​เมื่อ 2025-06-03 ว่า “For the foreseeable future, the Go team will stop pursuing syntactic language changes for error handling” และ​จะ​ปิด​ข้อ​เสนอ​ที่​ว่า​ด้วย ไวยากรณ์​ของ error handling เป็น​หลัก​ทุก​ฉบับ “without further investigation”

ประกาศ​นั้น​พูด​ถึง ไวยากรณ์ และ​พูด​ถึง อนาคต​ที่​มอง​เห็น​ได้ ไม่​ได้​บอก​ว่า error handling ของ Go จะ​ไม่​เปลี่ยน​อีก​เลย · แต่​มัน​พอ​จะ​บอก​ได้​ว่าการ​รอ​ไวยากรณ์​ที่​สั้น​กว่า​นี้ ไม่ใช่​แผนที่​ดี

สิ่ง​ที่​คุณ​คาด​จาก​ภาษา​เดิมสิ่ง​ที่ Go ให้​จริง
ความ​ผิด​พลาด​ลอย​ขึ้น​ไป​เอง​จนกว่า​จะ​มี​ใคร​รับไม่​ลอย ถ้า​คุณ​ไม่​คืน​มัน​ต่อ มัน​หาย​ไป​ตรง​นั้น
error พก stack trace มา​ด้วยมี​แค่​ข้อความ​จาก Error() string
เทียบ​ชนิด​ด้วย catch (FooException e)เทียบ​ด้วย errors.Is หรือ errors.As
มี AggregateException ให้​ใช้errors.Join ใน​ไลบรารี​มาตรฐาน​ตั้งแต่ Go 1.20
ไม่มี stack trace แปล​ว่า​บริบท​เป็น​งาน​ของ​คุณ

ถ้า​คุณ​มา​จาก C# หรือ Java คุณ​เคยชิน​กับ​การ​เปิด log แล้ว​เห็น​สาย​การ​เรียก​ทั้ง​สาย

Go ไม่​ให้​สิ่ง​นั้น สิ่ง​ที่​แทน​มัน​คือ​บริบท​ที่​คุณ​ห่อ​เอง​ทุก​ชั้น​ด้วย %w · error ที่​เขียน​ว่า "open failed" เฉยๆ จึง​แทบ​ไม่มี​ประโยชน์​ตอน​ตี​สาม

เอกสาร Effective Go ยก​ตัวอย่าง​รูป​ที่​ควร​เป็น — "image: unknown format" คือ​ชื่อ package แล้ว​ตาม​ด้วย​สิ่ง​ที่​ผิด​จริง

ค่า error ระดับ package หนึ่ง​ตัว​ต่อ​หนึ่ง​กรณี เทียบ​ด้วย errors.Is เพราะ​การ​ห่อ​ทำให้ == เป็น​เท็จ

sentinel errorsentinel errorค่า error ที่​ประกาศ​เป็น​ตัวแปร​ระดับ package ตัว​เดียว ใช้​เทียบ​ด้วย `errors.Is` เพื่อ​แยก​กรณี คือ​ค่า error ที่​ประกาศ​เป็น​ตัวแปร​ระดับ package ตัว​เดียว แล้ว​ผู้​เรียก​เทียบ​กับ​มัน​เพื่อ​แยก​กรณี ชื่อ​ขึ้น​ต้น​ด้วย Err เป็น​ธรรมเนียม

ปัญหา​เกิด​ตอน​ที่​มี​ใคร​สัก​คนกลาง​ทาง​ห่อ​มัน​เพื่อ​เติม​บริบท

package main
import (
"errors"
"fmt"
)
var ErrNoLines = errors.New("order: ออเดอร์ไม่มีรายการ")
func load(name string) error {
return fmt.Errorf("load %q: %w", name, ErrNoLines)
}
func main() {
err := load("dinner.json")
fmt.Println(err)
fmt.Println("err == ErrNoLines:", err == ErrNoLines)
fmt.Println("errors.Is:", errors.Is(err, ErrNoLines))
}
load "dinner.json": order: ออเดอร์ไม่มีรายการ
err == ErrNoLines: false
errors.Is: true

err ตัว​ที่​ผู้​เรียก​ได้​รับ ไม่ใช่ ErrNoLines อีก​ต่อ​ไป มัน​คือ error ตัว​ใหม่​ที่ ห่อ ตัว​เดิม​ไว้​ข้าง​ใน · == จึง​เป็น​เท็จ

errors.Is เดิน​ตาม​สาย​การ​ห่อ​ลง​ไป​ที​ละ​ชั้น​แล้ว​เทียบ​ทุก​ชั้น เอกสาร​ของ Go เขียน​ไว้​ตรงๆ ว่า​มัน​ควร​ถูก​ใช้​แทน​การ​เทียบ​ด้วย​ความ​เท่า​กัน​ธรรมดา

กริยา `%w` เปลี่ยน error ให้​เป็น​ส่วน​หนึ่ง​ของ API

fmt.Errorf มี​กริยา​สอง​ตัว​ที่​ดู​คล้าย​กัน​แต่​ผล​ต่าง​กัน​สิ้น​เชิง

%v เอาแต่​ข้อความ​มา​ต่อ สาย​การ​ห่อ​ขาด​ตรง​นั้น ผู้​เรียก​เทียบ​อะไร​ไม่​ได้​อีก · %w เก็บ​ตัว​เดิม​ไว้​จริง errors.Is และ errors.As จึง​เดิน​ต่อ​ได้

ราคา​ที่​จ่าย​คือ​สิ่ง​ที่ blog ทางการ​เรียก​ว่า การ​ห่อ error ทำให้ error ตัว​นั้น​กลาย​เป็น ส่วน​หนึ่ง​ของ API ของ​คุณ · ถ้า​วัน​หนึ่ง​คุณ​เลิก​ห่อ ErrNoLines ผู้​เรียก​ที่​เช็​กมัน​อยู่​จะ​พัง โดยที่ compiler ไม่​เตือน​สัก​คำ

error ชนิด​ของ​ตัว​เอง เมื่อ​ผู้​เรียก​ต้องการ​ข้อมูล

หัวข้อ​ที่​มีชื่อ​ว่า “error ชนิด​ของ​ตัว​เอง เมื่อ​ผู้​เรียก​ต้องการ​ข้อมูล”

sentinel บอก​ได้​แค่​ว่า “กรณี​ไหน” ถ้า​ผู้​เรียก​ต้อง​รู้​ว่า field ไหน​ผิด ต้อง​ใช้​ชนิด​ของ​ตัว​เอง

ErrBadTable บอก​ว่า​หมายเลข​โต๊ะ​ผิด แต่​บอก​ไม่​ได้​ว่า​ค่าที่​ส่ง​มา​คือ​อะไร และ​ช่วง​ที่​ยอมรับ​คือ​เท่าไร

เมื่อ​ผู้​เรียก​ต้องการ ข้อมูล ไม่ใช่​แค่​ชนิด ให้​ประกาศ struct แล้ว​ให้​มัน​มี method Error

package main
import (
"errors"
"fmt"
)
var ErrBadTable = errors.New("order: หมายเลขโต๊ะไม่ถูกต้อง")
// FieldError พก field ค่าที่ผิด และเหตุผล มาให้ผู้เรียกอ่านได้
type FieldError struct {
Field string
Value string
Reason string
Err error
}
func (e *FieldError) Error() string {
return fmt.Sprintf("field %s = %q: %s", e.Field, e.Value, e.Reason)
}
// Unwrap ต้องเขียนเอง ไม่ได้มาฟรีจากการมี field ชื่อ Err
func (e *FieldError) Unwrap() error { return e.Err }
func checkTable(n int) error {
if n < 1 || n > 40 {
return &FieldError{
Field: "table",
Value: fmt.Sprint(n),
Reason: "ต้องอยู่ระหว่าง 1 ถึง 40",
Err: ErrBadTable,
}
}
return nil
}
func main() {
err := fmt.Errorf("validate: %w", checkTable(0))
var fe *FieldError
if errors.As(err, &fe) {
fmt.Println("field:", fe.Field, "· เหตุผล:", fe.Reason)
}
fmt.Println("errors.Is(err, ErrBadTable):", errors.Is(err, ErrBadTable))
}
field: table · เหตุผล: ต้องอยู่ระหว่าง 1 ถึง 40
errors.Is(err, ErrBadTable): true

errors.As ต่าง​จาก errors.Is ตรง​ที่​มัน​ไม่​ได้​ตอบ​ว่า​ใช่​หรือ​ไม่ใช่​เท่านั้น · มัน​เดิน​หา​ชั้น​ที่​เข้า​กับ target แล้ว เขียน​ค่า​ลง​ใน target ให้​คุณ​อ่าน field ต่อ​ได้

สังเกต​ว่า checkTable ยัง​คืน error ไม่ใช่ *FieldError ทั้ง​ที่​ข้าง​ใน​สร้าง *FieldError · เหตุผล​อยู่​ใน​หัวข้อ typed nil ข้าง​ล่าง และ​มัน​สำคัญ​กว่า​ที่​หน้าตา​บอก

`errors.As` panic ได้​สาม​แบบ ถ้า​ใส่ target ผิด

target ต้อง​เป็น pointer ไป​ยัง​ชนิด​ที่​เข้า​กับ error หรือ pointer ไป​ยัง​ชนิด interface ใด​ก็ได้

ข้อความ panic ที่​คุณ​จะ​เจอ​มี​สาม​แบบ​คนละ​กรณี​กัน:

  • errors: target cannot be nil — ส่ง nil เข้า​ไป​ตรงๆ
  • errors: target must be a non-nil pointer — ส่ง​ค่าที่​ไม่ใช่ pointer
  • errors: *target must be interface or implement error — ชี้​ไป​ยัง​ชนิด​ที่​ไม่มี method Error

ใน​ตัวอย่าง​ข้าง​บน Error() ใช้ pointer receiver ชนิด​ที่​เข้า​กับ error จึง​เป็น *FieldError และ target จึง​ต้อง​เป็น **FieldError ซึ่ง​คือ​สิ่ง​ที่ &fe ให้ · ถ้า Error() ใช้ value receiver กฎ​นี้​จะ​เปลี่ยน​ไป

ผู้​ใช้​ที่​กรอก​ผิด​หลาย​ช่อง ควร​เห็น​ทุก​ช่อง​ใน​รอบ​เดียว ไม่ใช่​แก้​ที​ละ​รอบ

errors.Joinerrors.Joinรวม error หลาย​ตัว​เป็น​ตัว​เดียว​ที่​มี `Unwrap() []error` และ​คืน `nil` เอง​เมื่อ​ทุก​ตัว​เป็น nil ใช้​รายงาน​ทุก​ข้อ​ที่​ผิด​พร้อม​กัน รวม error หลาย​ตัว​เป็น​ตัว​เดียว​ที่​มี Unwrap() []error · errors.Is และ errors.As เดิน​ทุก​กิ่ง​ของ​มัน​ให้

นี่​คือ​ส่วน​ที่​บท​นี้​เพิ่ม​ลง​ใน internal/order/order.go · Line กับ Order มา​จาก​บท​ที่ 2 และ​ย่อ​เหลือ​เฉพาะ field ที่​หัวข้อ​นี้​ใช้ ของ​จริง​มี field มากกว่า​นี้

package order
import (
"errors"
"fmt"
"strconv"
)
// sentinel: ตัวแปรระดับ package ตัวเดียว ขึ้นต้นด้วย Err
var (
ErrNoLines = errors.New("order: ออเดอร์ไม่มีรายการ")
ErrBadTable = errors.New("order: หมายเลขโต๊ะไม่ถูกต้อง")
)
// Line กับ Order มาจากบทที่ 2 · ย่อเหลือเฉพาะ field ที่หัวข้อนี้ใช้
type Line struct {
SKU string
Qty int
}
type Order struct {
ID string
Table int
Lines []Line
}
// FieldError คือ error ชนิดของตัวเอง ผู้เรียกอ่าน field ได้
type FieldError struct {
Field string
Value string
Reason string
Err error
}
func (e *FieldError) Error() string {
return fmt.Sprintf("field %s = %q: %s", e.Field, e.Value, e.Reason)
}
func (e *FieldError) Unwrap() error { return e.Err }
// Validate รายงานทุกข้อที่ผิด ไม่ใช่ข้อแรก
func (o Order) Validate() error {
var errs []error
if o.Table < 1 || o.Table > 40 {
errs = append(errs, &FieldError{
Field: "table", Value: strconv.Itoa(o.Table),
Reason: "ต้องอยู่ระหว่าง 1 ถึง 40", Err: ErrBadTable,
})
}
if len(o.Lines) == 0 {
errs = append(errs, &FieldError{
Field: "lines", Value: "[]",
Reason: "ต้องมีอย่างน้อยหนึ่งรายการ", Err: ErrNoLines,
})
}
for i, l := range o.Lines {
if l.Qty <= 0 {
errs = append(errs, &FieldError{
Field: "lines[" + strconv.Itoa(i) + "].qty",
Value: strconv.Itoa(l.Qty), Reason: "ต้องมากกว่าศูนย์",
})
}
}
return errors.Join(errs...) // คืน nil เองเมื่อ errs ว่าง ไม่ต้องเช็ก len
}

บรรทัด​สุดท้าย​คือ​ที่​ที่​คน​พลาด​บ่อย​ที่สุด และ​มัน​พลาด​ใน​ทาง​ที่​เกิน​จำเป็น

errors.Join คืน nil เอง​เมื่อ​ทุก​ตัว​ที่​ส่ง​เข้าไป​เป็น nil หรือ​ไม่​ได้​ส่ง​อะไร​เลย · การ​เขียน if len(errs) == 0 { return nil } ครอบ​ไว้​อีก​ชั้น​จึง​ไม่​ผิด แต่​ไม่​จำเป็น

package main
import (
"errors"
"fmt"
)
func main() {
fmt.Println("ไม่ส่งอะไรเลย:", errors.Join() == nil)
fmt.Println("ส่ง nil ล้วน:", errors.Join(nil, nil) == nil)
err := errors.Join(errors.New("ข้อแรก"), errors.New("ข้อที่สอง"))
fmt.Println(err)
}
ไม่ส่งอะไรเลย: true
ส่ง nil ล้วน: true
ข้อแรก
ข้อที่สอง

เอกสาร​ของ errors.Join ระบุ​ว่า​ข้อความ​ที่​ได้​คือ​ข้อความ​ของ​แต่ละ​ตัว​ต่อ​กัน โดย​มี​การ​ขึ้น​บรรทัด​ใหม่​คั่น​ระหว่าง​กัน · สอง​ข้อ​จึง​ได้​สอง​บรรทัด ไม่ใช่​บรรทัด​เดียว​คั่น​ด้วย​จุลภาค

flowchart TD
  A["error ตัวเดียวที่ Validate คืนออกมา"] --> B["ค่าจาก errors.Join<br/>มี Unwrap() []error"]
  B --> C["FieldError<br/>field = table"]
  B --> D["FieldError<br/>field = lines"]
  C -->|"Unwrap()"| E["ErrBadTable"]
  D -->|"Unwrap()"| F["ErrNoLines"]
  G["errors.Is(err, ErrNoLines)"] -.->|"เดินลงทุกกิ่ง จนกว่าจะเจอ"| B

คำ​บรรยาย​ภาพ: Validate คืน error ตัว​เดียว ข้าง​ใน​เป็น​ค่า​จาก errors.Join ที่​แตก​ออก​เป็น​สอง​กิ่ง · แต่ละ​กิ่ง​เป็น FieldError ที่​ห่อ sentinel ของ​ตัว​เอง​ไว้​อีก​ชั้น · errors.Is จึง​ต้อง​เดิน​ทั้ง​ความ​กว้าง​และ​ความ​ลึก

ถ้า​เจอ​บทความ​ที่​แนะนำ library รวม error แปล​ว่า​มัน​เก่า

errors.Join เข้า​มา​ตั้งแต่ Go 1.20 ซึ่ง​ออก​เมื่อ 2023-02

บทความ​หรือ​คำ​ตอบ​ใน Stack Overflow ที่​ยัง​บอก​ให้​ลง go.uber.org/multierr หรือ github.com/hashicorp/go-multierror จึง​เขียน​ก่อน​หน้า​นั้น · ของ​แบบ​นี้​ยัง​ใช้ได้​อยู่ แต่​ใน​คอร์ส​นี้​เรา​ไม่​เพิ่ม​สิ่ง​ที่​พึ่งพา​จาก​ข้าง​นอก​เลย​สัก​ตัว

err != nil เป็น​จริง​ได้​ทั้ง​ที่​ไม่มี​ความ​ผิด​พลาด​เกิด​ขึ้น และ go vet ไม่​จับ​ให้

typed niltyped nilค่า interface ที่​ถือ pointer ที่​เป็น nil อยู่​ข้าง​ใน ทำให้ `err != nil` เป็น​จริง​ทั้ง​ที่​ไม่มี​ความ​ผิด​พลาด​เกิด​ขึ้น​จริง คือ​ค่า interface ที่​ถือ pointer ซึ่ง​เป็น nil อยู่​ข้าง​ใน

ค่า​แบบ interface ใน Go มี​สอง​ช่อง คือ​ชนิด กับ​ค่า · มัน​เท่ากับ nil ก็​ต่อ​เมื่อ ทั้ง​สอง​ช่อง​ว่าง · การ​ยัด *FieldError ที่​เป็น nil เข้าไป ทำให้​ช่อง​ชนิด​ไม่​ว่าง​แล้ว

package main
import "fmt"
type FieldError struct{ Field string }
func (e *FieldError) Error() string { return "field " + e.Field }
// ❌ แบบดิบ ห้ามลอกไปใช้ — ประกาศชนิดที่คืนเป็นชนิดคอนกรีต
func validateWrong(id string) *FieldError {
if id == "" {
return &FieldError{Field: "id"}
}
return nil // nil ตัวนี้เป็น *FieldError ไม่ใช่ error
}
func callerWrong(id string) error {
return validateWrong(id) // ยัด nil pointer ลงในช่องชนิดของ interface
}
// ✅ ถูก — ประกาศชนิดที่คืนเป็น error เสมอ
func validateRight(id string) error {
if id == "" {
return &FieldError{Field: "id"}
}
return nil
}
func main() {
fmt.Println("callerWrong:", callerWrong("A-1") != nil)
fmt.Println("validateRight:", validateRight("A-1") != nil)
}
callerWrong: true
validateRight: false

callerWrong("A-1") ผ่าน​การ​ตรวจ​ทุก​ข้อ ไม่มี​อะไร​ผิด​เลย แต่​ผู้​เรียก​ที่​เขียน if err != nil จะ​เข้าไป​ใน​กิ่ง​ที่​ผิด​พลาด

กฎ​ที่​กัน​เรื่อง​นี้​ได้​ทั้งหมด​มี​ข้อ​เดียว — ฟังก์ชัน​ที่​รายงาน​ความ​ผิด​พลาด ให้​ประกาศ​ชนิด​ที่​คืน​เป็น error เสมอ ไม่ใช่​ชนิด​คอนกรีต​ของ error นั้น

เครื่อง​มือ​ที่​จับ​ได้ ไม่ใช่ `go vet`

go vet ไม่มี​ตัว​ตรวจ​สำหรับ​กรณี​นี้ · โปรแกรม​ข้าง​บน​จึง​ผ่าน​ทั้ง go build และ go vet

ตัว​ที่​จับ​ได้​คือ staticcheck รหัส SA4023 ซึ่ง​รายงาน​ว่าการ​เทียบ​แบบ​นี้​ไม่มี​ทาง​เป็น​จริง หรือ​ไม่มี​ทาง​เป็น​เท็จ

แต่​มัน​ไม่​ทำงาน​ให้​เอง​ถ้า​คุณ​ใช้ gopls ตาม​ค่า​เริ่ม​ต้น · เอกสาร​ตัว​วิเคราะห์​ของ gopls ระบุ SA4023 ว่า Default: off คุณ​ต้อง​เปิด​เอง หรือ​รัน staticcheck เป็น​คำ​สั่ง​แยก

Go ไม่มี assertEquals ใน​ไลบรารี​มาตรฐาน สิ่ง​ที่​แทน​มัน​คือ slice ของ​กรณี​ทดสอบ​กับ t.Run

package testing ไม่มี assertEquals และ​ไม่มี assert ตัว​ไหน​เลย · สิ่ง​ที่​คุณ​เขียน​แทน​คือ if ธรรมดา แล้ว​เรียก t.Errorf เมื่อ​ค่าที่​ได้​ไม่​ตรง​กับ​ค่าที่​คาด

table-driven testtable-driven testสำนวน​ทดสอบ​ของ Go ที่​เก็บ​กรณี​ทดสอบ​เป็น slice ของ struct แล้ว​วน​ด้วย `t.Run` ที​ละ​กรณี เก็บ​กรณี​ทดสอบ​เป็น slice ของ struct แล้ว​วน​ที​ละ​กรณี · มัน​เป็น​สำนวน​ที่​คุณ​จะ​เจอ​ใน​ไลบรารี​มาตรฐาน​เอง​แทบ​ทุก package

// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์
// วางไว้ที่ internal/order/order_test.go ข้างๆ order.go ของหัวข้อก่อนหน้า
func TestValidate(t *testing.T) {
tests := []struct {
name string
order Order
wantErr error
}{
{"ok", Order{Table: 4, Lines: []Line{{SKU: "a", Qty: 1}}}, nil},
{"table too low", Order{Table: 0, Lines: []Line{{SKU: "a", Qty: 1}}}, ErrBadTable},
{"no lines", Order{Table: 4}, ErrNoLines},
}
for _, tt := range tests { // ตั้งแต่ Go 1.22 ไม่ต้องเขียน tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.order.Validate()
if !errors.Is(err, tt.wantErr) {
t.Errorf("Validate() error = %v, want %v", err, tt.wantErr)
}
})
}
}

สาม​อย่าง​ใน​นั้น​เป็น​ธรรมเนียม​ที่​คุ้ม​ค่า​จะ​จำ

  • t.Run(tt.name, ...) ทำให้​ผลลัพธ์​ที่​ล้ม​บอก​ชื่อ​กรณี​ตรงๆ เช่น TestValidate/no_lines
  • t.Parallel() ปล่อย​กรณี​ย่อย​ให้​รัน​พร้อม​กัน · ตั้งแต่ Go 1.22 ตัว​แปร​ของ​ลูป​เป็น​ของ​ใหม่​ทุกร​อบ บรรทัด tt := tt แบบ​เก่า​จึง​ไม่​ต้อง​มี​แล้ว
  • errors.Is(err, tt.wantErr) ใช้ได้​ทั้ง​กรณี​ที่​คาด​ว่า​ผิด​และ​กรณี​ที่​คาด​ว่า​ไม่​ผิด เพราะ errors.Is(nil, nil) เป็น​จริง

ข้อความ​ใน​บรรทัด t.Errorf ก็​มี​รูป​ที่ทาง​การ​แนะนำ — เขียน​สิ่ง​ที่​เรียก แล้ว​ตาม​ด้วย ค่าที่​ได้​จริง​กับ​ค่าที่​คาด f(x) = got, want

ข้อความ​ของ error ห้าม​ขึ้น​ต้น​ด้วย​ตัว​ใหญ่ และ​ห้าม​จบ​ด้วย​เครื่องหมาย​วรรค​ตอน

เอกสาร Code Review Comments กำหนด​รูป​ของ​ข้อความ​ไว้ เพราะ​ข้อความ​ของ error มัก​ถูก​เอา​ไป​ต่อ​ท้าย​บริบท​อื่น

fmt.Errorf("load %q: %w", name, err) จะ​ได้​ผล​อ่าน​ออก​ก็​ต่อ​เมื่อ​ข้อความ​ข้าง​ใน​ไม่​ขึ้น​ต้น ด้วย​ตัว​ใหญ่​และ​ไม่มี​จุด​ปิด​ท้าย

ข้อ​ยกเว้น​คือ​ชื่อ​เฉพาะ​และ​ตัว​ย่อ เช่น JSON หรือ HTTP ที่​ขึ้น​ต้น​ประโยค​ได้​ตาม​ปกติ

ผู้​เรียก​ชั้น​นอก​สุด​ของ​โปรแกรม​บรรทัด​คำ​สั่ง​คือ main และ​หน้าที่​เดียว​ของ​มัน​คือ​แปล error เป็น​ตัวเลข

main ไม่​ควร​มี​ตรรกะ มัน​ควร​เรียก​ฟังก์ชัน​ที่​ทดสอบ​ได้​ตัว​หนึ่ง แล้ว​ตัดสิน​ใจ​จาก​ค่าที่​ได้

package main
import (
"errors"
"fmt"
"os"
)
var ErrNoLines = errors.New("order: ออเดอร์ไม่มีรายการ")
func run(args []string) error {
if len(args) == 0 {
return errors.New("ต้องระบุชื่อ file ของออเดอร์")
}
return fmt.Errorf("validate %q: %w", args[0], ErrNoLines)
}
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "orderctl:", err)
if errors.Is(err, ErrNoLines) {
os.Exit(2) // ออเดอร์ไม่ผ่านการตรวจ
}
os.Exit(1) // ผิดพลาดอย่างอื่น
}
}
$ orderctl
orderctl: ต้องระบุชื่อ file ของออเดอร์
$ echo $?
1
$ orderctl dinner.json
orderctl: validate "dinner.json": order: ออเดอร์ไม่มีรายการ
$ echo $?
2

สังเกต​ว่า​ข้อความ​ที่ run คืน​ออก​มา​ไม่มี​คำ​ว่า orderctl: นำ​หน้า เพราะ main เติม​ให้​แล้ว · ถ้า​ใส่​ทั้ง​สอง​ที่ ผู้​ใช้​จะ​เห็น​ชื่อ​โปรแกรม​ซ้ำ​สอง​ครั้ง​ใน​บรรทัด​เดียว

ตัวเลข 1 กับ 2 เป็น​ข้อ​ตกลง​ของ​โปรแกรม​นี้​เอง ไม่ใช่​มาตรฐาน​ของ Go · สิ่ง​ที่​เป็น​กฎ​จริง​มี​ข้อ​เดียว คือ 0 แปล​ว่า​สำเร็จ

os.Exit ไม่​รัน defer ที่​ค้าง​อยู่ · นี่​คือ​เหตุผล​ที่​ทุก​อย่าง​ต้อง​อยู่​ใน run แล้ว​ให้ main เป็น​ชั้น​บาง​ที่สุด​ที่​ทำได้

การ​แยก​แบบ​นี้​ยัง​ทำให้​เขียน​เทสต์​ให้ run ได้​ตรงๆ โดย​ไม่​ต้อง​รัน​โปรแกรม​จริง · บท​ที่ 7 จะ​ต่อย​อด​จาก run ตัว​นี้​ด้วย flag.FlagSet แล้ว​เพิ่ม argument สำหรับ​ที่​เขียน​ออก เพื่อ​ให้​เทสต์​อ่าน​สิ่ง​ที่​โปรแกรม​พิมพ์​ได้

`panic` เป็น​เครื่อง​มือ​ของ​ขอบ ไม่ใช่​ของ​ทาง​เดิน​ปกติ

คน​ที่​ย้าย​มา​จาก​ภาษา​ที่​มี exception มัก​เผลอ​ใช้ panic กับ recover แทน try กับ catch

เอกสาร Code Review Comments มี​หัวข้อ​ชื่อ #dont-panic แยก​ต่าง​หาก​จาก​หัวข้อ​เรื่อง การ​จัดการ error · หลัก​คือ​ความ​ผิด​พลาด​ที่​คาด​หมาย​ได้ ให้​คืน​เป็น​ค่า

recover มี​ที่ทาง​ของ​มัน​จริง แต่​เป็น​ที่​ขอบ​ของ​ระบบ เช่น​ตัว​รับคำ​ขอ​ที่​ไม่​ควร​ล้ม​ทั้ง โปรแกรม​เพราะ​คำขอ​เดียว · เรื่อง​นั้น​อยู่​ใน​บท​ที่ 8 ห้าม​เอา defer recover() ไป​ครอบ​ทุก​ฟังก์ชัน

Go 1.26 เพิ่ม `errors.AsType` — ยัง​ไม่​ใช้​ใน​คอร์ส​นี้

คอร์ส​นี้​ตั้ง​ขั้น​ต่ำ​ไว้​ที่ go 1.25.0 ทุก​อย่าง​ข้าง​บน​จึง​ใช้ errors.As แบบ​เดิม

Go 1.26 เพิ่ม errors.AsType[E error](err) ที่​คืน​ค่า​กับ bool แทน​การ​เขียน target · มัน​อ่าน​ง่าย​กว่า แต่ E ต้อง​เข้า​กับ error ดัง​นั้น​การ​เอา​มัน​ไป​ดึง​กิ่ง​ของ errors.Join ด้วย errors.AsType[interface{ Unwrap() []error }] คอม​ไพล์​ไม่​ผ่าน

การ​ดึง​กิ่ง​ยัง​ต้อง​เขียน​แบบ​เดิม คือ​ประกาศ​ตัวแปร interface{ Unwrap() []error } แล้ว​ส่ง pointer ของ​มัน​ให้ errors.As

orderctl ตรวจ​ออ​เด​อร์​ได้​แล้ว และ​บอก​ทุก​ข้อ​ที่​ผิด​ใน​รอบ​เดียว

คุณ​เลือก​ได้​แล้ว​ว่า​กรณี​ไหน​ใช้ sentinel และ​กรณี​ไหน​ต้อง​ใช้ error ชนิด​ของ​ตัว​เอง · เกณฑ์​คือ​ผู้​เรียก​ต้องการ​แค่​แยก​กรณี หรือ​ต้องการ​ข้อมูล​ไป​แสดง​ต่อ

คุณ​รู้​ว่า errors.Join คืน nil เอง​เมื่อ​ไม่มี​อะไร​ผิด และ​รู้​ว่า typed nil ทำ​ให้​การ​เช็ก​ที่​ดู​ปลอดภัย​ที่สุด​เชื่อ​ไม่​ได้

บท​หน้า​เรา​ทำให้ orderctl เป็น​เครื่อง​มือ​จริง — subcommand ด้วย flag.FlagSet, log แบบ​มี​โครงสร้าง​ด้วย log/slog และ​การ​เขียน​สถานะ​ลง disk แบบ​ที่​ล้ม​กลาง​ทาง​แล้ว​ไม่​ทำ​ของ​เดิม​พัง


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้

บท​นี้​อิง​ต้นทาง​ที่​ลง​วัน​ที่​กำกับ อ่าน​ต่อ​ได้​โดยตรง:

  • The Go Programming Language Specification — Errors (2026-05-26) — error เป็น interface ที่​มี method เดียว และ nil คือ​ค่าที่​แทน “ไม่มี​ความ​ผิด​พลาด”
  • pkg.go.dev — fmt.Errorf (เข้า​ถึง 2026-08-27) — %w หลาย​ตัว​ใน​ข้อความ​เดียว​ทำให้​ผลลัพธ์​มี Unwrap() []error
  • pkg.go.dev — errors.Join (เข้า​ถึง 2026-08-27) — คืน nil เมื่อ​ทุก​ตัว​เป็น nil และ​ต่อ​ข้อความ​โดย​คั่น​ด้วย​การ​ขึ้น​บรรทัด​ใหม่
  • Working with Errors in Go 1.13 (2019-10-17) — การ​ห่อ error ทำให้ error ตัว​นั้น​เป็น​ส่วน​หนึ่ง​ของ API
  • [ On | No ] syntactic support for error handling (Robert Griesemer, 2025-06-03) — “the Go team will stop pursuing syntactic language changes for error handling”
  • Go Code Review Comments — error-strings และ dont-panic (เข้า​ถึง 2026-08-27) — รูป​ของ​ข้อความ error และ​หัวข้อ​แยก​เรื่อง​การ​ไม่​ใช้ panic
  • Effective Go — Errors (เข้า​ถึง 2026-08-27) — ตัวอย่าง "image: unknown format"
  • gopls analyzers (เข้า​ถึง 2026-08-27) — SA4023 มี​สถานะ Default: off

เช็กความเข้าใจ — บทที่ 6

ข้อ 1 / 3

ฟังก์ชันกลางทางเขียน `return fmt.Errorf("load %q: %w", name, ErrNoLines)` แล้วผู้เรียกเช็กด้วย `err == ErrNoLines` จะได้อะไร และทำไม