SA4023 报的总是 nil 比较:typed nil 装进 interface 后为什么不等于 nil
先看现象:同一个检查,三种输出形态
SA4023 的短描述是 “Impossible comparison of interface value with untyped nil”,实际输出有三种形态:
this comparison is never true(SA4023)——对x == nil报,常见于拿到一个 interface 返回值再判空。this comparison is always true(SA4023)——对err != nil报。- 附带的 related information:
(*pkg.T).Fn never returns a nil interface value、the lhs of the comparison is the 1st return value of this function call。
golangci-lint 的 issue #4032 里贴过真实输出(与 golangci-lint 本身无关,作者 ldez 用独立 staticcheck 复现了同一结果):
$ staticcheck ./...
foo.go:30:23: this comparison is always true (SA4023)
foo.go:30:12: the lhs of the comparison is the 1st return value of this function call
foo.go:21:20: (*github.com/golangci/sandbox.whatever).Close never returns a nil interface value
机制:interface 是两个字段,typed nil 不等于 nil
Go 的接口值由类型和数据两部分组成(runtime 里是 type/itab + data)。接口等于 nil,要求类型和数据都为空。把一个「类型为 *T、值为 nil」的指针装进 interface,类型字段不是空的,整个接口就不等于 nil。Go FAQ 专门写了这一条:https://go.dev/doc/faq#nil_error
最小可运行用例:
package main
import "fmt"
type myErr struct{ code int }
func (e *myErr) Error() string { return "myErr" } // 不碰 e.code,nil 指针也能调
func parse(bad bool) *myErr {
if bad {
return &myErr{code: 1}
}
return nil
}
// 隐式转换:*myErr(nil) 被装箱成 error,接口非 nil
func parseErr(bad bool) error {
return parse(bad)
}
func main() {
var err error = parseErr(false)
fmt.Println(err == nil) // false
fmt.Println(err) // myErr —— 调到了 nil 接收者的方法
}
这段代码能编译、能跑,err == nil 却是 false。上层写 if err != nil { return err } 会把一个「看起来是 nil」的错误一路往上抛;反过来,上层写 if err == nil { 继续 },分支永远进不去。
更隐蔽的是 Close() 这类方法:
type whatever struct{ c container[io.Closer] }
func (w *whatever) Close() error {
err := w.c.FuncAny(...) // 内部把 *SomeError 装进 any 再断言回 error
if err == nil {
return nil
}
...
}
调用方 if err := w.Close(); err != nil 恒真,错误处理代码永远执行。issue #4032 的原始代码甚至不是「判断错」,而是类型断言后返回了错误类型,闭包一跑就 panic:interface conversion: interface is nil, not error。ldez 给的修法是把泛型包装器写成两个类型参数,不再经 any 中转:
type container[T any, Y any] struct{ t T }
func (c *container[T, Y]) FuncAny(fn func(t *T) Y) Y { return fn(&c.t) }
type whatever2 struct{ c container[io.Closer, error] }
func (w *whatever2) Close() error {
return w.c.FuncAny(func(closer *io.Closer) error { return (*closer).Close() })
}
判定条件:不是「函数永不返回 nil」
维护者 dominikh 在 issue #845 里解释过判定逻辑,两个条件同时成立才报:
- a) 被调函数不会返回 untyped nil(即不会真的
return nil成空接口); - b) 被调函数的返回值有可能是 typed nil,例如来自 slice/map 索引、返回传入参数、返回全局变量——静态无法确定非 nil 的那些值。
所以这条检查只在「用户可能把 typed nil 当成 nil 来比较」的场景触发,不是随便对一个返回值判空就报。这种不会报:
func fn() any { return &T{} }
func fn2() { if fn() != nil {} } // 不报,视为对未来的防御
反面例子在 issue #1194:函数 x(ret bool) *myErr 可能返回 nil,y() 把 x(ret) 直接作为 error 返回。作者以为是误报,dominikh 让他跑一次:
fmt.Println(y(false) == nil) // false
真 bug。这正是这条检查存在的价值。
修法:在返回 interface 的位置显式分支
规则只有一条:不要在返回 interface 的地方做「可能是 nil 的指针」的隐式装箱。要么在装箱前判断值,显式 return nil;要么让返回类型本身是具体指针类型,让调用方按具体类型判空。
issue #845 里 evcc 的修复就是这个形状。原代码:
func (lp *Loadpoint) GetCircuit() circuit.API {
return lp.circuit // lp.circuit 是 *xxx,nil 时接口非 nil
}
调用方 if lp.GetCircuit() == nil 被 SA4023 报 never true,但测试里 require.Nil(t, lps[0].GetCircuit()) 又是通过的——因为 testify 的 Nil 判断的是底层值。改成:
func (lp *Loadpoint) GetCircuit() circuit.API {
if lp.circuit == nil {
return nil // 返回 untyped nil,接口才是 nil
}
return lp.circuit
}
还有一个容易漏的位置:errors.As / 类型断言回来后不要原样返回。return v 里 v 若是具体指针类型,同样会重新装箱。
如果确认是「对未来实现变化的防御性判空」,用行级抑制,reason 必填:
//lint:ignore SA4023 fn 的签名允许返回 nil,这里保持防御
if fn() != nil {
}
判定顺序:先跑一次,别先改代码
# 1. 看官方对这条检查的说明
staticcheck -explain SA4023
# 2. 只跑这一条,排除其它检查的噪音
staticcheck -checks SA4023 ./...
# 3. 机读输出,和上一版本结果做 diff(SA4023 在 2026.2 被重写过)
staticcheck -f json ./... > sc.json
第 3 步是必要的。SA4023 在 Staticcheck 2026.2(v0.8.0)里被重新实现,新实现可能发现旧版本发现不了的问题,也可能漏掉旧版本报过的;随后 2026.2.1 修了「无约束泛型当作永不 nil」的误报,2026.2.2 继续修了接口值跨分支与全局变量加载时的 nilness,以及检查间接依赖函数调用时的崩溃。低于 2026.2.2 的版本,别拿 SA4023 的新告警当真。
更早的泛型误报也有记录:func foo[T any](x T) { if any(x) == nil {} } 配 foo[error](nil) 实际打印 “is nil”,旧版却报 never true(issue #1242,已修)。
最小复现优先于读规则:把可疑函数抽出来,go run 一行 fmt.Println(x == nil),看它到底返回什么。SA4023 报错的每一条,基本都能这样在 30 秒内定性。
别和这两条混起来
- SA4031:Checking never-nil value against nil。针对静态可确定永不为 nil 的表达式(例如
&T{}、new(T)),不涉及接口装箱。 - SA5011:可能的 nil 指针解引用。Staticcheck 2026.2 起因内部改动被停用,是否恢复没有时间表,替代是 gopls 里的 nilness 分析器(https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/nilness)。原先靠 SA5011 拦的问题需要换工具覆盖,别把「没有告警」当成「没有问题」。
项目地址与参考
- Staticcheck(原 go-tools):https://github.com/dominikh/go-tools
- 检查清单与 SA4023 说明:https://staticcheck.dev/docs/checks/#SA4023
- Go FAQ:为什么 nil 指针的 error 不等于 nil:https://go.dev/doc/faq#nil_error
- 判定条件讨论:https://github.com/dominikh/go-tools/issues/845
- 被误当误报的真 bug(自定义 error 指针):https://github.com/dominikh/go-tools/issues/1194
- 泛型误报(已修):https://github.com/dominikh/go-tools/issues/1242
- golangci-lint 集成侧复现与修法:https://github.com/golangci/golangci-lint/issues/4032
版本事实:SA4023 在 2026.2(v0.8.0)被重写;2026.2.1 修无约束泛型与「非 typed nil 场景」误报;2026.2.2 修接口值跨分支、全局变量 nilness、间接依赖函数调用崩溃。以上均以 GitHub Releases 为准。