Hình dung hai cách một thao tác báo cho bạn biết nó hỏng. Exception giống chuông báo cháy: có chuyện là cả toà nhà bị giật ra ngoài, bạn chạy qua mấy tầng cầu thang rồi tới cửa thoát hiểm mới biết chuyện gì — và không có gì trong bản vẽ mặt bằng nói cho bạn rằng chỗ này có thể réo chuông. Còn error của Go là một tờ phiếu giao đính kèm mỗi lần trao việc: "xong rồi" hoặc "hỏng, đây là lý do". Bạn cầm nó trên tay, và bạn phải tự quyết đọc hay vứt. Cả triết lý xử lý lỗi của Go gói trong hình ảnh đó — và nó cũng là thứ gây tranh cãi nhất, thứ người mới phàn nàn đầu tiên: if err != nil lặp đi lặp lại khắp nơi.

Bài này giải thích vì sao, và cho thấy nó không tệ như vẻ ngoài.

error chỉ là một interface

type error interface {
	Error() string
}

Đó là toàn bộ. Một method, trả về chuỗi.

  không tìm thấy: DH-1 | kiểu thật *main.LoiKhongTim

Nghĩa là lỗi trong Go là giá trị bình thường: gán được vào biến, truyền được vào hàm, cất được vào slice, so sánh được. Không có cơ chế ngôn ngữ đặc biệt nào cả — chỉ là tờ phiếu bạn cầm trên tay.

Tạo lỗi có hai cách cơ bản:

errors.New("hết hạn")
fmt.Errorf("không đọc được %s: %w", ten, err)

Vì sao không dùng exception

Lập luận của nhóm Go, và tôi thấy nó có lý sau khi viết Go một thời gian:

Lỗi hiện ra trong chữ ký hàm. func Doc() ([]byte, error) nói rõ hàm này có thể hỏng — bản vẽ mặt bằng có ghi chỗ nào réo chuông được. Ở Java, unchecked exception không xuất hiện ở đâu cả, và bạn chỉ biết khi nó nổ.

Đường xử lý lỗi là đường mã bình thường. Không có nhánh vô hình nhảy qua nhiều tầng ngăn xếp. Đọc từ trên xuống là thấy hết.

Bỏ qua lỗi phải cố ý. Bài 7 đã đo: nhận thiếu giá trị trả về là lỗi biên dịch. Muốn lờ đi thì phải gõ _ — phải chủ động vứt tờ phiếu. Ở Java, nuốt exception là không làm gì.

Cái giá rất thật: mã dài hơn. Một hàm gọi bốn thứ có thể hỏng sẽ có bốn khối if err != nil. Không có cách nào tránh, và nhóm Go đã từ chối nhiều đề xuất thêm cú pháp tắt.

Bốn mẫu bạn sẽ gõ hằng ngày

Trả thẳng lên:

if err != nil {
	return err
}

Thêm ngữ cảnh rồi trả lên — đây là mẫu nên dùng mặc định:

if err != nil {
	return fmt.Errorf("đọc cấu hình %s: %w", ten, err)
}

Dấu %w giữ liên kết tới lỗi gốc. Bài mai sẽ nói vì sao nó quan trọng.

Xử lý rồi tiếp tục:

if err != nil {
	log.Printf("bỏ qua dòng hỏng: %v", err)
	continue
}

Bỏ qua có chủ đích:

_ = f.Close()   // rõ ràng là đang bỏ qua

Quy ước viết thông điệp lỗi

Thư viện chuẩn theo bốn quy tắc, và cộng đồng theo rất chặt:

Chữ thường, không dấu chấm cuối. "không tìm thấy tệp" chứ không phải "Không tìm thấy tệp.". Lý do: lỗi thường bị bọc, và bạn muốn chuỗi ghép lại đọc được như một câu.

Không lặp lại từ "error" hay "lỗi". fmt.Errorf("lỗi khi đọc: %w", err) cho ra lỗi khi đọc: lỗi khi mở: .... Viết fmt.Errorf("đọc cấu hình: %w", err).

Thêm ngữ cảnh mà tầng dưới không biết. Tầng đọc tệp biết "không mở được"; chỉ tầng của bạn biết "đang nạp cấu hình cho dịch vụ X".

Kèm dữ liệu giúp gỡ lỗi — tên tệp, mã đơn hàng, chỉ số dòng.

Đừng vừa log vừa trả lên

Đây là lỗi phổ biến nhất trong mã Go thật:

if err != nil {
	log.Printf("hỏng: %v", err)   // <- rồi
	return err                     // <- lại trả lên
}

Lỗi đó sẽ được log ở mọi tầng nó đi qua. Một lỗi, năm dòng log giống nhau.

Quy tắc: hoặc xử lý, hoặc trả lên — không làm cả hai. Chỉ tầng ngoài cùng (handler HTTP, main) mới log.

error là giá trị nên làm được nhiều thứ

Vì lỗi chỉ là giá trị, bạn dùng nó theo những cách exception không cho:

var loi []error
for _, v := range ds {
	if err := xuLy(v); err != nil {
		loi = append(loi, err)
	}
}
return errors.Join(loi...)     // Go 1.20+: gộp nhiều lỗi thành một

errors.Join cho phép trả về nhiều lỗi cùng lúc, và errors.Is vẫn tìm được qua từng cái. Thử gom các exception của nhiều vòng lặp lại thành một rồi ném — bạn sẽ thấy ngay vì sao "lỗi là giá trị" tiện.

Nếu muốn thấy cái giá của việc làm ngược lại ngay trong dự án mình, tìm một phút:

grep -rn 'log\..*err' --include='*.go' | grep -A1 'return err'

Mỗi chỗ vừa log vừa return err là một lỗi sẽ xuất hiện nhiều lần trong log sản xuất. Xoá dòng log đi và để tầng ngoài cùng lo — bạn vừa làm log sạch hơn trong đúng vài giây.

Mẫu số chung

Cả ngành chia làm hai phe về cùng một câu hỏi: khi một hàm hỏng, chuyện đó có nằm trong kiểu trả về không, và người gọi có buộc phải đối mặt không?

  • Phe "lỗi là giá trị": Go trả error, Rust trả Result<T, E>, Haskell có Either, Swift có Result và try. Thất bại nằm ngay trong kiểu; người gọi cầm tờ phiếu và phải làm gì đó với nó.
  • Phe "exception": Java (unchecked), Python, C#, C++, JavaScript — lỗi bung ngược ngăn xếp, không hiện trong chữ ký, réo chuông rồi tìm cửa thoát.

Hai chi tiết đáng mang theo. Một: checked exception của Java từng cố đặt lỗi vào chữ ký — đúng ý tưởng của Go — nhưng bị chê phiền tới mức cả cộng đồng né, và unchecked thắng; còn Go cưỡng chế được vì lỗi chỉ là giá trị trả về bình thường, không phải một loại chữ ký riêng phải khai. Hai: cái giá thật của "lỗi là giá trị" là dài dòng, nên mọi hệ sinh thái đi lối này sớm muộn mọc ra cú pháp làm nó dễ thở — toán tử ? của Rust (chính xác là cái tắt mà nhóm Go cố ý từ chối), %w + errors.Is/As của Go, Result của Kotlin, Try/Either của Scala.

Sợi chỉ chung: các kiểu hỏng của một hàm là một phần hợp đồng của nó. Ngôn ngữ giấu chúng trong một kênh điều khiển vô hình đổi chút ngắn gọn hôm nay lấy một lớp bất ngờ về sau — if err != nil trông lắm lời, nhưng nó là cái hợp đồng đó được viết ra thành chữ, ngay trước mắt bạn.

Ngày mai: errors.Is, errors.As và wrapping — cách kiểm lỗi cho đúng.

Bài tập làm thử

Bài 1 (đọc hiểu). Đoạn mã sau có biên dịch được không? Nếu không, tại sao?

func chia(a, b int) (int, error) {
	if b == 0 {
		return 0, fmt.Errorf("chia cho 0")
	}
	return a / b, nil
}

func main() {
	v := chia(7, 2)
	fmt.Println(v)
}
Đáp án

Không biên dịch được. Lỗi: assignment mismatch: 1 variable but chia returns 2 values. chia trả về hai giá trị (int và error), nên người gọi buộc phải nhận cả hai — dùng v, err := chia(7, 2), hoặc bỏ lỗi có chủ đích bằng v, _ := chia(7, 2). Trình biên dịch không cho lờ giá trị trả về một cách tình cờ.

Bài 2 (sửa lỗi). Đoạn mã dưới đây phạm phải lỗi phổ biến nhất trong mã Go thật theo bài viết. Chỉ ra vấn đề và sửa.

func XuLyDonHang(id string) error {
	don, err := layDon(id)
	if err != nil {
		log.Printf("hỏng: %v", err)
		return err
	}
	return luuDon(don)
}
Đáp án

Vấn đề: vừa log vừa trả lỗi lên (log.Printf rồi return err). Nếu hàm gọi XuLyDonHang cũng log lỗi trước khi trả lên tiếp, thì cùng một lỗi sẽ bị ghi vào log nhiều lần ở mọi tầng nó đi qua. Quy tắc: hoặc xử lý, hoặc trả lên — không làm cả hai. Sửa lại: chỉ trả lỗi lên (thêm ngữ cảnh nếu cần), để tầng ngoài cùng (handler HTTP hoặc main) mới log:

func XuLyDonHang(id string) error {
	don, err := layDon(id)
	if err != nil {
		return fmt.Errorf("xử lý đơn %s: %w", id, err)
	}
	return luuDon(don)
}

Bài 3 (vận dụng thực tế). Bạn cần xử lý một danh sách các dòng dữ liệu; dòng nào lỗi thì bỏ qua chứ không dừng cả chương trình, nhưng vẫn phải ghi log để biết dòng nào hỏng. Viết đoạn mã dùng đúng một trong "bốn mẫu bạn sẽ gõ hằng ngày" đã nêu trong bài.

Đáp án
for _, dong := range dsDong {
	if err := xuLy(dong); err != nil {
		log.Printf("bỏ qua dòng hỏng: %v", err)
		continue
	}
	// dùng kết quả của dòng thành công
}

Đây là mẫu "xử lý rồi tiếp tục" — khác với "trả thẳng lên" (dừng hàm ngay) và "bỏ qua có chủ đích" (_ = ... khi thực sự không quan tâm tới lỗi).

Bài 4 (bẫy/đánh đổi). Vì sao nhóm phát triển Go cố ý không thêm cú pháp tắt kiểu ? của Rust để giảm số lần gõ if err != nil? Nêu đúng hai lý do cốt lõi được nhắc trong bài (lợi ích đổi lấy điều gì).

Đáp án

Go coi việc lỗi hiện rõ trong chữ ký hàm và đường xử lý lỗi nằm ngay trong luồng mã bình thường (không có nhánh vô hình nhảy qua nhiều tầng ngăn xếp như exception) là lợi ích quan trọng hơn sự ngắn gọn. Cái giá phải trả là mã dài hơn — if err != nil lặp lại khắp nơi — và nhóm Go chấp nhận đánh đổi đó, từ chối thêm cú pháp tắt, vì cho rằng phải "đọc từ trên xuống là thấy hết" quan trọng hơn viết ít ký tự hơn.

Bài 5 (đọc hiểu — interface error). error trong Go được định nghĩa như thế nào, và vì sao định nghĩa đó lại là lý do lỗi trong Go "gán được vào biến, truyền được vào hàm, cất được vào slice"?

Đáp án
type error interface {
	Error() string
}

error chỉ là một interface có đúng một method. Vì lỗi trong Go chỉ là một giá trị bình thường thoả mãn interface này (không có cơ chế ngôn ngữ đặc biệt như exception), nó có mọi tính chất của một giá trị Go thông thường: gán vào biến, truyền qua tham số, lưu trong slice, so sánh được — khác hẳn exception vốn phải bung ngược ngăn xếp qua một kênh điều khiển riêng.