Bài 24 đã nói có hai cách khai lỗi: sentinel và kiểu lỗi. Hình dung một lỗi như cái thẻ hành lý: sentinel là cái thẻ màu ai cũng nhận ra ("thất lạc"), còn kiểu lỗi riêng là cái thẻ ghi chi tiết — chuyến nào, của ai — để người xử lý bóc ra dùng. Bài này về cách viết kiểu lỗi cho đúng.
Khi nào cần
Chỉ khi lỗi phải mang dữ liệu mà người gọi dùng được:
type LoiKhongTim struct {
Loai string
Ma string
}
func (e *LoiKhongTim) Error() string {
return fmt.Sprintf("không tìm thấy %s %s", e.Loai, e.Ma)
}
Người gọi lấy lại dữ liệu bằng errors.As như bài 24:
var e *LoiKhongTim
if errors.As(err, &e) {
w.WriteHeader(404)
fmt.Fprintf(w, "không có %s", e.Ma)
}
Nếu người gọi chỉ cần biết loại lỗi mà không cần dữ liệu, dùng sentinel — cái thẻ màu là đủ, đơn giản hơn nhiều.
Receiver con trỏ hay giá trị
Quy ước mạnh trong Go: dùng con trỏ.
func (e *LoiKhongTim) Error() string // đúng
func (e LoiKhongTim) Error() string // gây nhầm lẫn
Lý do là method set ở bài 12: với receiver con trỏ, chỉ *LoiKhongTim thoả mãn error. Với receiver giá trị thì cả hai đều thoả mãn, và errors.As phải khớp đúng kiểu bạn truyền vào — nên sẽ có lúc errors.As(err, &e) với e là LoiKhongTim không khớp lỗi được trả về dưới dạng *LoiKhongTim.
Một quy tắc: con trỏ cho kiểu lỗi, và luôn trả &LoiKhongTim{...}.
Cài Unwrap để bọc lỗi khác
type LoiKho struct {
ThaoTac string
Err error
}
func (e *LoiKho) Error() string { return e.ThaoTac + ": " + e.Err.Error() }
func (e *LoiKho) Unwrap() error { return e.Err }
Có Unwrap, kiểu của bạn tham gia được vào cây lỗi: errors.Is và errors.As đi xuyên qua nó. Đây là để nguyên cái thẻ cũ khi gắn thêm thẻ mới, nên vẫn lần ngược được cả hành trình.
Không có Unwrap, lỗi bên dưới bị chôn — người gọi không kiểm được errors.Is(err, sql.ErrNoRows) nữa; bạn vừa xé sạch thẻ cũ, không ai truy được kiện hàng từ đâu tới. Đây là lỗi thiết kế hay gặp trong thư viện Go tự viết.
Cài Is khi cần so sánh tuỳ biến
Mặc định errors.Is so bằng ==. Muốn hai lỗi khác thực thể vẫn "bằng nhau":
func (e *LoiHTTP) Is(target error) bool {
t, ok := target.(*LoiHTTP)
return ok && t.Ma == e.Ma
}
Giờ errors.Is(err, &LoiHTTP{Ma: 404}) đúng với mọi lỗi 404, không cần cùng thực thể.
Dùng tiết chế — nó làm ngữ nghĩa errors.Is khác mặc định, và người đọc phải biết mới đoán được.
Kiểu lỗi là API công khai
Đây là phần tôi muốn nhấn mạnh.
Ngay khi ai đó viết errors.As(err, &e) với kiểu của bạn, cấu trúc của nó thành hợp đồng — hệ thống băng chuyền bắt đầu đọc từng ô trên cái thẻ của bạn:
Đổi tên trường → mã người dùng không biên dịch được.
Đổi receiver từ con trỏ sang giá trị → errors.As của họ ngừng khớp, im lặng.
Bỏ Unwrap → mọi errors.Is qua lỗi của bạn ngừng hoạt động, im lặng.
Nên hãy quyết định sớm: kiểu lỗi này là công khai (viết hoa, tài liệu, giữ tương thích) hay nội bộ (viết thường, chỉ dùng trong package). Đừng để nó ở giữa.
Với thư viện, một cách an toàn là chỉ xuất khẩu sentinel và giữ struct nội bộ:
var ErrKhongTim = errors.New("không tìm thấy")
type loiKhongTim struct{ ma string } // viết thường
func (e *loiKhongTim) Is(t error) bool { return t == ErrKhongTim }
Người dùng kiểm bằng errors.Is(err, kho.ErrKhongTim) mà không phụ thuộc cấu trúc bên trong.
Đừng tạo quá nhiều kiểu lỗi
Mã Go viết theo kiểu Java hay có LoiA, LoiB, LoiC cho từng tình huống. Trong thực tế, người gọi chỉ quan tâm vài nhóm:
Đầu vào sai → 400. Không tìm thấy → 404. Không có quyền → 403. Xung đột → 409. Lỗi hệ thống → 500.
Năm nhóm đó phủ gần hết API HTTP. Chi tiết hơn thì để trong thông điệp, đừng để trong kiểu.
Nếu chỉ soi một thứ sau bài này, tìm một kiểu lỗi có bọc lỗi khác rồi kiểm nó có giữ thẻ cũ không, trong ba mươi giây:
grep -A2 'func (e \*LoiCuaBan)' *.go | grep -c Unwrap
Nếu bằng 0, mọi errors.Is đi qua lỗi đó đang thất bại im lặng. Thêm bốn dòng Unwrap là sửa xong.
Mẫu số chung
Một lỗi là dữ liệu, không chỉ là một thông điệp — mô hình hoá thất bại thành một giá trị mang thông tin có cấu trúc (một mã, cái id không tìm thấy) là một lựa chọn có chủ đích, và bạn chỉ mang theo thứ người gọi sẽ hành động dựa vào; nếu họ chỉ cần biết loại, một sentinel trơn là đủ. Chuyện này ở đâu cũng có: enum lỗi có trường của Rust, lớp exception mang dữ liệu của Java/Python, đối tượng lỗi gắn thẻ của JS. Và người gọi rẽ theo nhóm, không đẻ một kiểu cho mỗi tình huống — năm rọ kiểu-HTTP phủ gần hết API, chi tiết mịn hơn để trong thông điệp chứ không trong kiểu. Thiết kế bề mặt lỗi theo cách người gọi ra quyết định, và giữ số kiểu ít.
Điều thứ hai: đừng bao giờ chôn nguyên nhân, và coi hình dạng của lỗi là một hợp đồng. Bọc lỗi thì phải giữ cái gốc còn với tới được — Unwrap của Go, "cause" của exception Java, raise ... from của Python, InnerException của .NET — vì một lớp bọc nuốt mất nguyên nhân là một cái hố đen khi gỡ lỗi, và nó lặng lẽ làm hỏng mọi phép kiểm-theo-nhóm nằm dưới. Và ngay khi người gọi soi trường hay kiểu của lỗi, cấu trúc đó là một API đã xuất bản: đổi tên một trường, lật một receiver, bỏ Unwrap — mã họ gãy, đôi khi chẳng có lỗi nào cả — đúng bài học biểu-diễn-của-bạn-là-hợp-đồng như entity-làm-DTO và đánh phiên bản API. Nên quyết sớm một kiểu lỗi là công khai hay nội bộ, và với vai thư viện thì ưu tiên phơi ra một sentinel ổn định hơn là một struct đổi được.
Ngày mai: xử lý lỗi cho ra hồn — bọc ở đâu, log ở đâu, trả gì cho người gọi.
Bài tập làm thử
Bài 1 (đọc hiểu). Cho kiểu lỗi sau, giải thích tại sao errors.As(err, &e) sẽ luôn thất bại nếu hàm nào đó trả về lỗi bằng return LoiKhongTim{Loai: "don", Ma: "x"} (không có dấu &):
type LoiKhongTim struct {
Loai string
Ma string
}
func (e *LoiKhongTim) Error() string {
return fmt.Sprintf("không tìm thấy %s %s", e.Loai, e.Ma)
}
Đáp án
Error() được khai với receiver con trỏ (*LoiKhongTim), nên chỉ *LoiKhongTim thoả mãn interface error, không phải LoiKhongTim (giá trị). Dòng return LoiKhongTim{...} (không có &) thực ra sẽ gây lỗi biên dịch nếu hàm khai trả về error, vì giá trị LoiKhongTim{} không thoả mãn error. Nếu giả sử đâu đó code ép được việc này qua, thì khi gọi errors.As(err, &e) với e *LoiKhongTim, nó sẽ không khớp được vì kiểu động bên trong err không phải *LoiKhongTim. Bài học: quy ước mạnh của Go là luôn dùng receiver con trỏ cho kiểu lỗi, và luôn trả về &LoiKhongTim{...}.
Bài 2 (sửa lỗi). Kiểu lỗi sau bọc một lỗi khác nhưng làm mất khả năng errors.Is xuyên qua nó. Sửa lại:
type LoiKho struct {
ThaoTac string
Err error
}
func (e *LoiKho) Error() string { return e.ThaoTac + ": " + e.Err.Error() }
Đáp án
Thiếu method Unwrap(). Không có nó, lỗi bên dưới (e.Err) bị "chôn" — người gọi không thể dùng errors.Is(err, sql.ErrNoRows) để kiểm tra nguyên nhân gốc dù nó thực sự nằm bên trong. Sửa:
func (e *LoiKho) Unwrap() error { return e.Err }
Có Unwrap, cả errors.Is và errors.As sẽ đi xuyên qua LoiKho để tìm/so khớp lỗi bên dưới.
Bài 3 (vận dụng — code Go). Viết một hàm LayDon trong tầng repository, chuyển lỗi sql.ErrNoRows (lỗi của thư viện database/sql, không thuộc miền nghiệp vụ) thành một lỗi thuộc miền của bạn (*LoiKhongTim), đồng thời bọc các lỗi khác kèm ngữ cảnh, theo đúng mẫu bài viết đưa ra.
Đáp án
func (r *Repo) LayDon(ma string) (*Don, error) {
var d Don
err := r.db.QueryRow("SELECT ... WHERE ma = ?", ma).Scan(&d.ID)
if errors.Is(err, sql.ErrNoRows) {
return nil, &LoiKhongTim{Loai: "don", Ma: ma}
}
if err != nil {
return nil, fmt.Errorf("truy vấn đơn %s: %w", ma, err)
}
return &d, nil
}
Việc dịch sql.ErrNoRows sang *LoiKhongTim giúp tầng trên không cần biết database/sql tồn tại — nó chỉ cần errors.As(err, &loiKhongTim).
Bài 4 (bẫy/đánh đổi). Một thư viện nội bộ đổi kiểu lỗi công khai LoiKhongTim từ receiver con trỏ (*LoiKhongTim) sang receiver giá trị (LoiKhongTim) trong một bản cập nhật "chỉ để dọn code cho gọn". Giải thích hậu quả cụ thể cho những người dùng thư viện đang viết errors.As(err, &e) với e *LoiKhongTim, và tại sao đây là lỗi đặc biệt nguy hiểm.
Đáp án
Toàn bộ lời gọi errors.As(err, &e) của người dùng (với e khai là *LoiKhongTim) sẽ ngừng khớp, tức luôn trả về false — dù err thực chất chứa đúng loại lỗi đó (nay dưới dạng LoiKhongTim giá trị chứ không phải con trỏ). Nguy hiểm đặc biệt vì đây là lỗi im lặng: mã vẫn biên dịch được (nếu hàm Error() giờ khai theo receiver giá trị nên cả LoiKhongTim lẫn *LoiKhongTim đều thoả mãn error), không có exception, không có cảnh báo — chỉ đơn giản là logic xử lý 404/lỗi đặc thù không còn được kích hoạt, và người dùng rơi vào nhánh xử lý lỗi mặc định (ví dụ 500) một cách khó hiểu. Bài viết nhấn mạnh: kiểu lỗi công khai là một hợp đồng — đổi receiver là một thay đổi phá vỡ tương thích ngầm (breaking change) dù không hiện ra như vậy trên bề mặt API.
Bài 5 (đọc hiểu/khái niệm). Bài viết khuyên "đừng tạo quá nhiều kiểu lỗi" và liệt kê năm nhóm lỗi phủ gần hết API HTTP. Nêu năm nhóm đó và giải thích nguyên tắc chung đứng sau lời khuyên này.
Đáp án
Năm nhóm: đầu vào sai → 400; không tìm thấy → 404; không có quyền → 403; xung đột → 409; lỗi hệ thống → 500. Nguyên tắc chung: người gọi (client, handler) chỉ thực sự cần rẽ nhánh xử lý theo vài nhóm lớn này — chi tiết cụ thể hơn (ví dụ "không tìm thấy đơn hàng" hay "không tìm thấy khách hàng") nên nằm trong thông điệp của lỗi, không cần tạo ra một kiểu lỗi Go riêng cho mỗi tình huống nghiệp vụ. Tạo quá nhiều kiểu lỗi (kiểu Java LoiA, LoiB, LoiC...) làm tăng bề mặt API phải bảo trì và giữ tương thích mà không đem lại lợi ích tương ứng, vì phần lớn logic xử lý chỉ cần biết lỗi thuộc nhóm nào trong năm nhóm trên.