Hình dung bọc lỗi như đóng một món hàng vào những chiếc thùng lồng nhau. Mỗi tầng mã nhận lỗi từ tầng dưới, cho vào một thùng mới, và dán lên ngoài cái nhãn ghi ngữ cảnh của mình — "tầng kho", "tầng api". Nhưng điều quyết định là: bên dưới lớp nhãn đó, cái mã vạch gốc có còn quét được không. %w đóng thùng mà vẫn giữ nguyên thùng con bên trong — quét xuyên qua mọi tầng vẫn thấy món hàng gốc. %v thì chỉ chụp cái nhãn rồi vứt cả thùng con đi — dòng chữ in ra giống hệt, nhưng mã vạch biến mất. Go 1.13 thêm cơ chế bọc lỗi này, và nó đổi hẳn cách viết mã xử lý lỗi. Bài này về ba hàm bạn cần và một cái bẫy một ký tự.

Bọc bằng %w

goc := &LoiKhongTim{Ma: "DH-9"}
t1 := fmt.Errorf("tầng kho: %w", goc)
t2 := fmt.Errorf("tầng api: %w", t1)
  chuỗi lỗi: tầng api: tầng kho: không tìm thấy: DH-9

Mỗi tầng thêm ngữ cảnh của mình, và thông điệp cuối cùng đọc như một dấu vết từ ngoài vào trong.

Quan trọng hơn: %w giữ liên kết tới lỗi gốc, tạo thành một chuỗi mà bạn đi ngược lại được — mã vạch còn nguyên dưới mọi lớp nhãn.

Cái bẫy một ký tự

fmt.Errorf("dùng %v", goc)          // dùng %v thay vì %w
  errors.Is với %v = false     <<< MẤT liên kết

%v chỉ lấy chuỗi của lỗi. Thông điệp in ra giống hệt, nhưng liên kết đã đứt và errors.Is không tìm thấy gì nữa — bạn giữ bức ảnh chụp cái nhãn, còn thùng hàng thì không còn.

Đây là lỗi im lặng đúng kiểu khó chịu nhất: log trông bình thường, chỉ có logic kiểm lỗi ở tầng trên là sai. Và không có công cụ nào trong thư viện chuẩn cảnh báo — go vet chỉ bắt được khi số lượng đối số sai.

Quy tắc: luôn dùng %w khi bọc lỗi, trừ khi bạn cố tình muốn cắt liên kết (ví dụ để không lộ chi tiết nội bộ ra API công khai).

Chỉ được một %w trong mỗi fmt.Errorf trước Go 1.20; từ 1.20 thì nhiều %w cũng được.

errors.Is: so sánh danh tính

var ErrHetHan = errors.New("hết hạn")
l := fmt.Errorf("gọi API: %w", ErrHetHan)
  errors.Is(l, ErrHetHan) = true
  l == ErrHetHan          = false     <<< SAI vì đã bọc

Đây là lý do errors.Is tồn tại. So sánh == chỉ đúng khi lỗi chưa bị bọc — và bạn không kiểm soát được điều đó, vì tầng nào cũng có thể bọc thêm.

errors.Is đi suốt chuỗi và so từng tầng — quét xuyên qua từng thùng. Nên mã kiểm lỗi của bạn không phụ thuộc vào việc có bao nhiêu tầng đã bọc.

Lỗi thư viện chuẩn cũng dùng được:

  errors.Is(err, os.ErrNotExist) = true

Đây là mẫu chuẩn để kiểm "tệp không tồn tại", thay cho việc so chuỗi thông điệp — thứ vừa mong manh vừa phụ thuộc ngôn ngữ hệ thống.

errors.As: lấy lại kiểu cụ thể

Khi lỗi mang dữ liệu bạn cần:

type LoiKhongTim struct{ Ma string }
func (e *LoiKhongTim) Error() string { return "không tìm thấy: " + e.Ma }

var target *LoiKhongTim
if errors.As(t2, &target) {
	fmt.Println(target.Ma)
}
  errors.As(t2, &target) = true -> lấy lại Ma="DH-9"

errors.As tìm trong chuỗi lỗi cái đầu tiên khớp kiểu, rồi gán vào biến bạn đưa — mở từng thùng tới khi gặp đúng loại món hàng, rồi lấy nó ra. Chú ý phải truyền con trỏ tới biến, không phải biến.

Đây là cách thay cho catch (LoiKhongTim e) của Java, và nó vẫn hoạt động dù lỗi đã bị bọc năm tầng.

errors.Unwrap: gỡ một tầng

  errors.Unwrap(t2) == t1 = true

Hiếm khi cần dùng trực tiếp — Is và As đã gọi nó bên trong. Nhưng biết nó tồn tại giúp hiểu cơ chế: một lỗi "bọc được" là lỗi có method Unwrap() error — là một cái thùng mà bạn mở ra được đúng một lớp.

Chọn sentinel hay kiểu lỗi

Hai cách khai báo lỗi để người gọi kiểm:

Sentinel — một biến lỗi cố định:

var ErrHetHan = errors.New("hết hạn")

Dùng khi chỉ cần biết "loại lỗi gì", không cần dữ liệu kèm theo. Kiểm bằng errors.Is.

Kiểu lỗi — struct cài error:

type LoiKhongTim struct{ Ma string }

Dùng khi cần mang dữ liệu. Kiểm bằng errors.As.

Lưu ý thiết kế: cả hai đều trở thành một phần API công khai của bạn. Người dùng sẽ viết errors.Is(err, kho.ErrHetHan), và đổi tên hay xoá nó là phá vỡ tương thích.

Cái bẫy con trỏ nil, lần nữa

Bài 17 đã đo. Nó áp dụng thẳng vào đây:

func f() error {
	var e *LoiKhongTim   // nil
	return e             // interface KHÔNG nil
}

Và errors.Is(f(), nil) cho false như tôi đo được. Luôn return nil tường minh.

Nếu chỉ nhớ một thứ từ cả bài, để nó là khác biệt giữa hai ký tự này:

goc := errors.New("gốc")
a := fmt.Errorf("bọc: %w", goc)
b := fmt.Errorf("bọc: %v", goc)
fmt.Println(errors.Is(a, goc), errors.Is(b, goc))

In ra true false. Hai dòng khác nhau đúng một ký tự, và một trong hai làm hỏng toàn bộ logic kiểm lỗi phía trên mà chẳng để lại dấu vết nào trong log.

Mẫu số chung

Mọi ngôn ngữ trưởng thành rồi cũng học đúng một bài: khi thêm ngữ cảnh cho lỗi, hãy bọc chứ đừng thay — giữ lại nguyên nhân gốc để tầng trên cùng vẫn hỏi được "thật ra chuyện gì đã hỏng". Cái được giữ là một chuỗi nhân quả, và cách truy vấn nó là theo danh tính hoặc theo kiểu, chứ không phải khớp chuỗi thông điệp.

  • Java có exception chaining: new IOException("...", cause) và getCause() đi ngược chuỗi; bắt theo kiểu (catch/instanceof) chính là errors.As.
  • Python có raise X from Y đặt __cause__, và traceback in ra "The above exception was the direct cause of..." — đúng cái dấu vết từ ngoài vào trong.
  • Rust có chuỗi source(), và anyhow với .context("...") gần như chính là fmt.Errorf("...: %w"), còn downcast_ref là errors.As.
  • .NET có InnerException.

Hai điều đáng mang theo. Một: khớp chuỗi thông điệp là phản-mẫu ở mọi nơi — mong manh và phụ thuộc ngôn ngữ hệ thống, đúng như chuyện so chuỗi thay cho os.ErrNotExist ở trên; vì vậy mọi hệ đều mọc ra cách hỏi theo danh tính (errors.Is) hoặc theo kiểu (errors.As/catch/downcast). Hai: cách phá hỏng chuỗi luôn trông gần như y hệt cách giữ nó — %v thay %w của Go, raise X mà quên from của Python, new Exception(msg) bỏ rơi cause của Java. Cả ba không mất gì nhìn thấy được cho tới khi ai đó ở tầng trên cố rẽ nhánh theo nguyên nhân và phát hiện nó đã biến mất. Sợi chỉ chung: một thông điệp lỗi là để con người đọc; một chuỗi lỗi là để mã truy vấn — đừng đánh đổi cái sau lấy cái trước chỉ vì trên màn hình chúng trông giống nhau.

Ngày mai: panic và recover — khi nào được phép, và vì sao hiếm khi đúng.

Bài tập làm thử

Bài 1 (đọc hiểu). Đoạn mã sau in ra gì?

goc := errors.New("gốc")
a := fmt.Errorf("bọc: %w", goc)
b := fmt.Errorf("bọc: %v", goc)
fmt.Println(errors.Is(a, goc), errors.Is(b, goc))
Đáp án

In ra true false. %w giữ liên kết tới lỗi gốc nên errors.Is(a, goc) tìm thấy goc trong chuỗi lỗi của a. %v chỉ lấy chuỗi thông báo của goc rồi vứt cả liên kết đi — thông điệp in ra giống hệt %w, nhưng errors.Is(b, goc) trả về false vì mã vạch đã mất, dù nội dung hiển thị y hệt.

Bài 2 (sửa lỗi). Đoạn mã dưới đây muốn kiểm tra lỗi hết hạn nhưng luôn thất bại dù lỗi thực sự là hết hạn. Tìm và sửa lỗi.

var ErrHetHan = errors.New("hết hạn")

func goiAPI() error {
	return fmt.Errorf("gọi API: %v", ErrHetHan)
}

func main() {
	err := goiAPI()
	if errors.Is(err, ErrHetHan) {
		fmt.Println("đã hết hạn, thử lại")
	}
}
Đáp án

Lỗi nằm ở %v trong fmt.Errorf — nó cắt đứt liên kết tới ErrHetHan, nên errors.Is(err, ErrHetHan) luôn trả false dù thông điệp in ra có chứa "hết hạn". Sửa bằng cách đổi %v thành %w:

func goiAPI() error {
	return fmt.Errorf("gọi API: %w", ErrHetHan)
}

Giờ errors.Is đi xuyên qua chuỗi lỗi và tìm thấy ErrHetHan.

Bài 3 (vận dụng thực tế). Bạn có một kiểu lỗi mang dữ liệu:

type LoiKhongTim struct{ Ma string }
func (e *LoiKhongTim) Error() string { return "không tìm thấy: " + e.Ma }

Viết đoạn mã kiểm tra một lỗi err (đã bị bọc qua nhiều tầng bằng %w) có phải là *LoiKhongTim hay không, và nếu đúng thì lấy ra giá trị Ma.

Đáp án
var target *LoiKhongTim
if errors.As(err, &target) {
	fmt.Println(target.Ma)
}

errors.As tìm trong chuỗi lỗi (đi qua mọi tầng %w) cái đầu tiên khớp kiểu *LoiKhongTim, rồi gán vào target. Chú ý bắt buộc phải truyền con trỏ tới biến (&target), không phải bản thân biến.

Bài 4 (bẫy/đánh đổi). Nêu cái bẫy trong đoạn mã sau, và giải thích vì sao nó có liên quan tới cái bẫy con trỏ nil đã học ở bài trước.

func f() error {
	var e *LoiKhongTim // nil
	return e
}

err := f()
fmt.Println(errors.Is(err, nil))
Đáp án

errors.Is(err, nil) trả về false, không phải true như trực giác mong đợi. Nguyên nhân: f() trả về một biến con trỏ *LoiKhongTim đang là nil, nhưng khi gán vào kiểu trả về error (là một interface), interface đó mang cặp (kiểu = *LoiKhongTim, giá trị = nil) — khác với interface nil thật sự (kiểu = nil, giá trị = nil). Đây chính là cái bẫy "interface nil khác con trỏ nil": hàm nên return nil tường minh thay vì trả về một biến con trỏ có thể đang nil.

Bài 5 (đọc hiểu — sentinel vs kiểu lỗi). Cho hai cách khai báo lỗi dưới đây, nêu khi nào dùng cách nào và dùng hàm gì để kiểm mỗi loại.

// Cách 1
var ErrHetHan = errors.New("hết hạn")

// Cách 2
type LoiKhongTim struct{ Ma string }
func (e *LoiKhongTim) Error() string { return "không tìm thấy: " + e.Ma }
Đáp án

Cách 1 (sentinel — biến lỗi cố định) dùng khi người gọi chỉ cần biết "loại lỗi gì", không cần dữ liệu kèm theo; kiểm bằng errors.Is. Cách 2 (kiểu lỗi — struct cài error) dùng khi lỗi cần mang thêm dữ liệu (ở đây là Ma); kiểm bằng errors.As. Cả hai đều trở thành một phần API công khai của package — đổi tên hay xoá chúng là phá vỡ tương thích với người dùng.