Có một bug làm mình mất gần trọn một buổi chiều, mà đoạn code gây ra chỉ dài ba dòng.

Chuyện là cái handler tra user theo ID. Hàm findUser trả về error, chỗ gọi viết đúng chuẩn Go: if err != nil { return err }. Rồi dòng ngay sau đó panic. Log in ra err = <nil>, nhưng điều kiện err != nil lại trả true. Hai chuyện đó không thể cùng đúng — trừ khi mình hiểu sai về nil trong Go.

Interface không phải một con trỏ — nó là một cặp

Trong Go, một biến kiểu interface là một cặp hai word: type và giá trị. Nên "nil" của interface phải nil cả hai.

var err error                 // (type: nil,       value: nil) → err == nil
var e *MyError = nil
err = e                       // (type: *MyError,  value: nil) → err != nil

Dòng thứ hai là gốc rễ của mọi thứ. Con trỏ e nil thiệt, nhưng khi nhét vô interface thì nó mang theo loại *MyError. Interface chỉ bằng nil khi cả type lẫn value đều nil. Có type nằm trong đó rồi thì nó không còn nil nữa — nó là "nil có giấy tờ".

Vỏ trong suốt rỗng ruột được soi bằng kính lúp Ảnh: Cái vỏ đúng hình dạng, đúng nhãn nhưng rỗng ruột — interface giữ đúng "loại" mà bên trong chẳng có gì, nên mọi phép so sánh với nil đều nhìn lầm. — ảnh minh hoạ do AI tạo (Grok Imagine)

Vì sao err != nil nói dối

Đây là đoạn code mình rút gọn từ đúng chỗ gây lỗi:

type NotFound struct{ ID string }

func (e *NotFound) Error() string {
	return "không tìm thấy " + e.ID   // e nil → panic ngay đây
}

func findUser(id string) error {
	var nf *NotFound                  // con trỏ nil
	if id == "" {
		nf = &NotFound{ID: id}
	}
	return nf                         // luôn trả interface có type *NotFound
}

Chỗ gọi:

err := findUser("u_123")
if err != nil {
	return fmt.Errorf("tra user: %w", err)  // đi nhánh lỗi dù tìm thấy
}

Với id = "u_123", nf vẫn nil, hàm trả interface (*NotFound, nil). Caller thấy err != nil nên báo lỗi. Sai âm thầm kiểu đó: tìm thấy user mà vẫn trả 500, còn metric error rate nhảy lên 100% mà chẳng có exception nào.

Tệ hơn là lúc có ai log err.Error(): method chạm vô e.ID trên receiver nil → panic. Mình từng thấy một service restart loop mười mấy lần vì đúng một hàm helper kiểu này.

Ba chỗ dễ dính nhất trong code thật

Kiện hàng dán nhãn hai nửa, một nửa in ký hiệu một nửa bỏ trống Ảnh: Kiện hàng dán nhãn hai nửa — một nửa in loại hàng, một nửa bỏ trống. Interface cũng vậy: đọc nhãn thì tưởng có hàng, mở ra mới biết rỗng. — ảnh minh hoạ do AI tạo (Grok Imagine)

Một, hàm trả error mà trả biến con trỏ thay vì nil tường minh. Đây là phần lớn ca mình gặp khi đi review.

Hai, constructor trả interface:

type Store interface{ Get(k string) (string, bool) }

func NewStore(cfg Config) Store {
	var s *redisStore
	if cfg.Addr != "" {
		s = &redisStore{addr: cfg.Addr}
	}
	return s    // cấu hình thiếu → trả về interface "không nil"
}

Caller check if st != nil rồi gọi st.Get(...) → panic nil pointer. Bug này lọt qua mọi unit test, trừ đúng cái test dựng cấu hình thiếu.

Ba, wrap lỗi bằng %w với biến con trỏ rồi errors.As gán vô target — target ra nil pointer, code lại deref tiếp ở dòng sau.

Bẫy cộng thêm: so sánh interface bằng ==

Interface so bằng == thì Go so cả type lẫn value, mà value không so sánh được (slice, map, func) thì panic runtime: comparing uncomparable type []int. Nên đừng nhét slice hay map vô struct error rồi đem so sánh. Muốn so sentinel thì dùng errors.Is, đừng ==.

Cách chặn

Trạm kiểm soát có checklist và cờ lê ở cửa nhà máy Ảnh: Trạm kiểm soát ngay cửa nhà máy, chặn tại biên thay vì đi vá từng kiện phía sau. Fix bẫy interface-nil cũng nên làm tại chỗ return, đừng rải nil-check khắp codebase. — ảnh minh hoạ do AI tạo (Grok Imagine)

Bốn thứ mình áp dụng từ sau lần đó, giờ nằm luôn trong checklist review:

  • Return nil tường minh. Hàm trả error mà không có lỗi thì viết return nil, đừng return nf. Nhìn hơi ngứa mắt nhưng đúng.
  • Constructor trả interface thì check trước khi return. Thêm if s == nil { return nil } ngay trên dòng return s.
  • Error type dùng value receiver, đừng dùng pointer. func (e NotFound) Error() string thì không có con trỏ nil nào để lọt vô interface. Value receiver xoá luôn cả lớp bug này.
  • Một test happy path. if err := findUser("u_123"); err != nil { t.Fatal(err) } — đúng một dòng này bắt được bug làm mình mất buổi chiều.

go vet không thấy bẫy này, nhưng staticcheck có check SA4023 (impossible comparison of interface value with untyped nil). Bật staticcheck trong CI là rẻ nhất.

Với code cũ thì errors.Is(err, (*NotFound)(nil)) trả true khi interface đang giữ con trỏ nil đúng loại; còn reflect.ValueOf(err).IsNil() chạy được nhưng chậm — chỉ để debug.

Chốt

nil trong Go là "không có gì" ở nhiều tầng: nil pointer, nil slice, nil map, nil channel, nil interface. Riêng interface có hai tầng, và đó là chỗ mình trả giá.

Bạn thử grep mấy dòng return s trong constructor xem — hàm nào khai báo con trỏ rồi trả về interface thì khả năng cao đang có bom hẹn giờ chờ đúng lúc cấu hình thiếu.

📋 Phụ lục thuật ngữ

Thuật ngữ Nghĩa ngắn
Interface value Cặp (type, value). Nil chỉ khi cả hai đều nil
Typed nil Con trỏ nil nhưng interface bọc nó vẫn mang type → không bằng nil
Value receiver Method khai báo trên giá trị, không trên con trỏ — không có nil receiver
errors.Is / errors.As So và bóc lỗi theo chuỗi wrap thay vì so ==
SA4023 Check của staticcheck: so interface với nil mà kết quả luôn cố định
%w Verb wrap lỗi trong fmt.Errorf, giữ nguyên chuỗi để errors.Is đi xuyên

Xem các bài viết khác