6.3 errors.Is/As 与自定义错误
6.1 定义了哨兵错误,6.2 用 %w 给它们加上了上下文。本节补上最后一块:如何在有包装的情况下,既判断错误的根因,又取回错误携带的结构化数据。errors.Is 负责前者,errors.As 负责后者,两者是 Go 错误处理的左右手。
本节把 TaskAPI 的错误体系收口:实现带字段的
ValidationError,用errors.As提取非法字段与取值,并确定哨兵错误 +%w包装 +Is/As判别的三层分层方案,为第 15 章统一错误响应做铺垫。
6.3.1 errors.Is:判断根因
errors.Is(err, target) 判断 err 的错误链中是否存在与 target 相等的错误。它替代了 6.1 里的 err == target:
err := store.Rename(99, "新")
fmt.Println(errors.Is(err, ErrNotFound)) // true
fmt.Println(errors.Is(err, ErrInvalidTitle)) // false
关键优势:它会自动 Unwrap。无论错误被包装了多少层,errors.Is 都能穿透到根因。这就是为什么 6.2 加了 %w 之后,判别依然有效——你不再需要关心错误被包了几层。
errors.Is 的匹配规则(按顺序):
err == target直接相等。err实现了Is(target error) bool方法且返回 true。- 递归
Unwrap后对每一层重复 1、2。
规则 2 是自定义判等的入口,6.3.4 会用到。规则 3 就是错误链遍历。
6.3.2 errors.As:取回结构化错误
errors.Is 只能回答「是不是这个错误」,但有时你需要错误里的字段。比如校验失败时,调用方想知道「是哪个字段不合法」。这时用自定义错误类型携带字段,再用 errors.As 取回:
type ValidationError struct {
Field string
Value string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed: field=%s value=%q", e.Field, e.Value)
}
errors.As(err, &target) 沿错误链查找第一个类型匹配的错误,并把它赋给 target。注意 target 必须是指向「错误类型」的指针:
var ve *ValidationError
if errors.As(err3, &ve) {
fmt.Println("As ValidationError:", ve.Field) // title
}
实测输出 As ValidationError: title。和 errors.Is 一样,errors.As 会自动 Unwrap,所以即使 ValidationError 被包在多层 %w 里也能取到。
| 函数 | 问的问题 | 目标参数 |
|---|---|---|
errors.Is(err, target) | 链里有和 target 相等的吗? | 一个错误值 |
errors.As(err, &target) | 链里有类型是 T 的错误吗? | 指向错误类型的指针 |
一个常见错误是把 errors.Is 和 errors.As 的用途搞混:想取字段却用 Is,想判哨兵却用 As。记法是:Is 比「值」,As 取「类型」。
6.3.3 用 %w 包装自定义错误
ValidationError 要被 errors.As 取回,同样需要被正确包装。在 Create 里:
func (m *MemStore) Create(title string) (Task, error) {
if len([]rune(title)) > 50 {
return Task{}, fmt.Errorf("create task: %w", &ValidationError{Field: "title", Value: title})
}
...
}
注意这里传的是 &ValidationError{...}——指针。因为 Error() 方法用指针接收者,只有 *ValidationError 满足 error,也只有它能被 errors.As 匹配到 *ValidationError 类型。如果把 Error() 改成值接收者,那么 errors.As(err, &ve) 里的 ve 就应该是 ValidationError 而非 *ValidationError——接收者与 As 的目标类型必须一致。
实测完整调用:
var ve *ValidationError
_, err3 := store.Create(string(long))
fmt.Println(err3) // create task: validation failed: field=title value="aaa..."
if errors.As(err3, &ve) {
fmt.Println("As ValidationError:", ve.Field, ve.Value[:5]+"...") // title aaaaa...
}
6.3.4 自定义 Is 方法
有时「相等」不是简单的 ==,而是有语义的。比如一个 OpError,当 HTTP 状态码 ≥ 500 时才认为「可重试」:
var ErrRetryable = errors.New("retryable")
type OpError struct{ Code int }
func (e *OpError) Error() string { return fmt.Sprintf("op error code=%d", e.Code) }
func (e *OpError) Is(target error) bool { return target == ErrRetryable && e.Code >= 500 }
实现了 Is(target error) bool 后,errors.Is 会调用它来判断匹配:
fmt.Println(errors.Is(&OpError{Code: 503}, ErrRetryable)) // true
fmt.Println(errors.Is(&OpError{Code: 400}, ErrRetryable)) // false
实测结果正是 true 和 false。自定义 Is 让错误可以表达「等价关系」,而不只是字面相等。TaskAPI 里如果以后引入限流错误,就可以用类似方式把「可重试」语义挂上去。
自定义 Is 有两个约束:target 通常要和某个哨兵错误比较,且方法不应自己调用 errors.Is 造成递归。保持简单:比较、判断字段、返回布尔。
6.3.5 errors.As 也能匹配接口类型
errors.As 的目标不一定是具体类型,也可以是接口。它查找的是「链中第一个实现了该接口的错误」,标准库的 net.Error 就是这么被消费的。用一个自定义接口演示:
type Temporary interface{ Temporary() bool }
type NetError struct {
msg string
temp bool
}
func (e *NetError) Error() string { return e.msg }
func (e *NetError) Temporary() bool { return e.temp }
func main() {
var netErr error = &NetError{msg: "timeout", temp: true}
wrapped := fmt.Errorf("request failed: %w", netErr)
var tmp Temporary
if errors.As(wrapped, &tmp) {
fmt.Println("temporary:", tmp.Temporary()) // true
}
}
实测输出 temporary: true(wrapped 的文本是 request failed: timeout)。这种「按能力而非按类型判别」的方式非常强大:调用方不必知道具体错误类型,只要错误声明了「我是可重试的」就能被识别。TaskAPI 以后接网络存储时,可以给超时错误加上 Temporary() bool,让上层统一决定是否重试。
6.3.6 三种错误策略的选择
到本节为止,TaskAPI 用到了三种错误表达方式。它们不是互斥的,而是各有适用场景:
| 方式 | 定义 | 判别 | 适用 |
|---|---|---|---|
| 哨兵错误 | var ErrX = errors.New(...) | errors.Is | 调用方需要区分的有限类别(NotFound/InvalidTitle) |
| 自定义类型 | type XError struct{...} | errors.As | 需要携带结构化字段(字段名、错误码) |
| 包装 | fmt.Errorf("...: %w", err) | 透传上述两者 | 每层补充上下文 |
经验法则:能用哨兵错误就用哨兵错误(最简单),需要字段时才上自定义类型,包装则几乎总是需要。三者组合起来就是完整的错误分层。
6.3.7 项目落地:TaskAPI 错误分层
综合第 6 章三节,TaskAPI 的错误体系定型为:
// 哨兵错误:调用方需要区分的失败类别。
var (
ErrNotFound = errors.New("task not found")
ErrInvalidTitle = errors.New("invalid title")
)
// ValidationError:需要携带字段信息时使用。
type ValidationError struct {
Field string
Value string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed: field=%s value=%q", e.Field, e.Value)
}
// 编译期断言:确保 *ValidationError 实现 error。
var _ error = (*ValidationError)(nil)
存储层的返回策略:
// 简单失败:包装哨兵错误
return fmt.Errorf("rename task %d: %w", id, ErrNotFound)
// 需要字段的失败:包装自定义类型
return fmt.Errorf("create task: %w", &ValidationError{Field: "title", Value: title})
上层判别:
func HandleError(err error) int {
switch {
case err == nil:
return 200
case errors.Is(err, ErrNotFound):
return 404
case errors.Is(err, ErrInvalidTitle):
return 400
}
var ve *ValidationError
if errors.As(err, &ve) {
log.Printf("invalid field: %s", ve.Field)
return 422
}
return 500
}
这段 HandleError 是第 15 章统一错误响应的雏形:先把哨兵错误映射成状态码,再用 errors.As 处理需要字段的场景,最后兜底 500。注意顺序——errors.As 的 ValidationError 分支放在哨兵判别之后,因为它是更具体的处理。
6.3.8 常见反模式
- 用字符串匹配判断错误:
strings.Contains(err.Error(), "not found")——脆弱且不可靠,一旦消息文案改动就失效。永远用errors.Is/errors.As。 errors.As的目标类型写错:var ve ValidationError; errors.As(err, &ve)在Error()用指针接收者时会失败。先看接收者,再决定As的目标。- 忽略
errors.As的返回值:errors.As返回 bool,不检查就使用ve可能拿到 nil 指针。 - 哨兵错误用
==判等但有包装:包装后==恒为 false,必须用errors.Is。
6.3.9 小结与检查清单
-
errors.Is判根因(值相等),自动穿透错误链 -
errors.As取回结构化错误类型,目标是错误类型的指针 - 自定义错误实现
Is(target error) bool可定义语义判等 - 用
%w包装,让Is/As能穿透 - 接收者类型决定
As的目标类型(指针接收者 →*T) - 用
switch+errors.Is做分层判别,As放具体分支 - 绝不靠字符串匹配判断错误类型
-
errors.As的目标也可以是接口,用于按能力(如Temporary())判别
第 6 章到此结束。Task 有了方法、有了关系(第 4 章),抽象出了接口(第 5 章),错误也分好了层(第 6 章)。下一章把这套代码从单个 main.go 拆成多个包:internal/task、internal/store、cmd/taskapi,并讲清包可见性、导出规则与循环依赖的规避。
阅读导航:上一节:6.2 fmt.Errorf 与 %w 包装 · 下一节:7.1 包的声明、导入与可见性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。