Bài 24 đã nói có hai cách khai lỗi: sentinel và kiểu lỗi. 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 — nó đơ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 eLoiKhongTim 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 }

Unwrap, kiểu của bạn tham gia được vào cây lỗi: errors.Iserrors.As đi xuyên qua nó.

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. Đâ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:

Đổ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.

Thử ba mươi giây

Tìm một kiểu lỗi trong dự án của bạn có bọc lỗi khác, rồi kiểm:

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.

Ngày mai: xử lý lỗi cho ra hồn — bọc ở đâu, log ở đâu, trả gì cho người gọi.