errors.AsTypeでGoのエラーを型安全に取り出す—Go 1.26の新関数

errors.AsTypeでGoのエラーを型安全に取り出す—Go 1.26の新関数 | mohablog

Go 1.26 で errors.AsType が追加されました。errors.As のジェネリクス版で、受け取る変数を宣言してからアドレスを渡す定型が消えます。戻り値で型付きのエラーをそのまま受け取れる。

目次

errors.Asのどこが不便だったか

errors.As は Go 1.13 から続く定番です。ただ書くたびに同じ手数がかかります。受け取る変数を先に宣言し、そのアドレスを渡す。この2ステップ。

ポインタ渡しの定型が毎回つく

var pathErr *os.PathError
if errors.As(err, &pathErr) {
    fmt.Println(pathErr.Op, pathErr.Path)
}

変数 pathErrif の外で宣言することになります。スコープが広がり、マッチしなければ nil のまま残る。取り出す型が増えるほど、この宣言が積み上がります。

渡し方を間違えると実行時に落ちる

errors.As の第2引数はコンパイル時には any です。型の正しさはランタイムまで検査されません。公式ドキュメントはこう書きます。

As panics if target is not a non-nil pointer to either a type that implements error, or to any interface type.
var pathErr os.PathError   // 値で宣言、しかもアドレスを渡し忘れ
errors.As(err, pathErr)    // ここで落ちる
panic: errors: target must be a non-nil pointer

値レシーバとポインタレシーバを取り違え、errors.As(err, &ve) が一度も真にならずログを30分眺めた、という失敗もこの延長にあります。型の不一致がコンパイルで止まらないための事故。

errors.AsTypeの書き方

_, err := os.Open("/no/such/file")

if pathErr, ok := errors.AsType[*os.PathError](err); ok {
    fmt.Println("op:", pathErr.Op)
    fmt.Println("path:", pathErr.Path)
}
op: open
path: /no/such/file

戻り値が (E, bool) になった

型パラメータ [*os.PathError] で取り出す型を指定します。戻り値は一致したエラー値と bool のペア。if の初期化文にそのまま収まり、変数のスコープが if ブロック内で閉じます。公式ドキュメントは動きをこう説明します。

AsType finds the first error in err’s tree that matches the type E, and if one is found, returns that error value and true. Otherwise, it returns the zero value of E and false.

公式が「多くの場合こちらを推奨」と書く

Go 1.26 のリリースノートは AsType を次のように位置づけます。

The new AsType function is a generic version of As. It is type-safe, faster, and, in most cases, easier to use.

errors パッケージのドキュメントも For most uses, prefer AsType. と明記します。As を捨てる話ではなく、既定の選択を AsType に寄せるという指針。

なぜ速いのか

errors.Astarget への代入可否を reflect で判定し、書き込みも reflect 経由で行います。AsType は型アサーション err.(E) だけで済む。リフレクションを通らないぶん、呼び出しのオーバーヘッドが減ります。公式の faster は、この実装差を指しています。

AsとAsTypeを一枚の表で見る

2つの関数の違いを並べます。

観点errors.Aserrors.AsType
シグネチャAs(err, target any) boolAsType[E error](err error) (E, bool)
受け取り方変数を宣言してアドレスを渡す戻り値で直接受け取る
型の制約target は任意(非error型も可)E は error を実装必須
誤用の検知実行時panicコンパイルエラー
内部実装reflectで代入型アサーション
追加バージョンGo 1.13Go 1.26

「非error型も可」の欄が、あとで As が残る理由になります。

ラップされたエラーからの取り出し

実務のエラーは、たいてい途中で %w にラップされて上位へ届きます。fmt.Errorf で文脈を足しながら伝播する形。AsType はこのラップの木を辿り、目的の型を探し当てます。

%wでラップした型を掘り出す

type ValidationError struct {
    Field string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("invalid field: %s", e.Field)
}

func handle() error {
    err := &ValidationError{Field: "email"}
    return fmt.Errorf("handle failed: %w", err)
}

func main() {
    err := handle()
    fmt.Println("err:", err)

    if ve, ok := errors.AsType[*ValidationError](err); ok {
        fmt.Println("field:", ve.Field)
    }
}
err: handle failed: invalid field: email
field: email

外側の fmt.Errorf が返すのはラップ用の内部型です。AsType はそこから Unwrap をたどり、*ValidationError に一致した時点で値を返します。呼び出し側は木構造を意識しません。

探索は深さ優先で走る

複数のエラーを errors.Join でまとめると、木は枝分かれします。公式ドキュメントは辿り方を明記します。

When err wraps multiple errors, As examines err followed by a depth-first traversal of its children.
err := errors.Join(
    errors.New("disk full"),
    &ValidationError{Field: "name"},
)

if ve, ok := errors.AsType[*ValidationError](err); ok {
    fmt.Println("matched:", ve.Field)
}
matched: name

errors.Join の並び順に沿い、先頭の "disk full" から順に見ます。最初に型が合った枝で探索は止まる。同じ型が複数ぶら下がる場合、返るのは最初の1つです。

ラップ設計とセットで効く

AsType が生きるのは、伝播の途中で %w を正しく付けているとき。ラップを %v で潰すと木が切れ、下層の型は取り出せません。取り出す AsType は、包むときに %w を付けている前提で動きます。包む側の流儀は Goのエラーハンドリングで失敗しない方法 に整理しています。

AsTypeへの乗り換えで引っかかる点

AsType の型パラメータ E には制約 error が付きます。error を実装しない型は指定できません。この制約は、乗り換えのときに引っかかりどころになります。

非errorインターフェースはAsが残る

一時的な失敗かを判定する慣習的なインターフェースを考えます。Error() を持たない形。

type temporary interface {
    Temporary() bool
}

// As なら通る
var tmp temporary
if errors.As(err, &tmp) {
    fmt.Println("temporary:", tmp.Temporary())
}

これを AsType に置き換えると、コンパイルで弾かれます。

errors.AsType[temporary](err) // temporary は error を満たさない
./main.go:20:24: temporary does not satisfy error (missing method Error)

error を埋め込まないインターフェースを的にするなら、As の柔軟さがそのまま要ります。この差は As のドキュメントにも書かれています。

As is equivalent to AsType but sets its target argument rather than returning the matching error and doesn’t require its target argument to implement error.

ポインタレシーバの型はAsType[*T]で受ける

Error() をポインタレシーバで実装した型は、*T だけが error を満たします。AsType[*T] が正解で、AsType[T] はコンパイルで弾かれる。

// func (e *ValidationError) Error() string の場合
errors.AsType[*ValidationError](err)  // 一致する
errors.AsType[ValidationError](err)   // コンパイルエラー
./main.go:14:9: ValidationError does not satisfy error (method Error has pointer receiver)

冒頭で触れた「値とポインタの取り違え」は、ここでコンパイラが止めてくれます。errors.As なら &ValidationError{} を渡しても文法上は通り、木の中の *ValidationError と型が合わず一致しないまま実行時を過ぎる。ズレを翻訳の段階で指摘できる点が AsType の利点です。

カスタムAsメソッドは両方が尊重する

エラー型が As(any) bool を自前で実装している場合、その判定は AsType でも呼ばれます。型アサーションが直接は合わなくても、カスタム Astrue を返せば一致とみなす。挙動は errors.As と揃えてあります。乗り換えでこの部分の意味は変わりません。

errors.Isとの使い分け

IsAs / AsType は役割が違います。値の同一性を見るか、型を取り出すか。

Sentinel errorはIs、型で取り出すならAsType

sql.ErrNoRowsio.EOF のような番兵値は errors.Is で照合します。特定の構造体エラーからフィールドを読みたいなら errors.AsType。判定の対象が値か型かで分かれます。

if errors.Is(err, sql.ErrNoRows) {
    // 該当レコードなし
}

if ve, ok := errors.AsType[*ValidationError](err); ok {
    log.Println("invalid:", ve.Field)
}
invalid: email

存在チェックだけなら戻り値のboolで足りる

フィールドが不要で「その型が含まれるか」だけ知りたいときも、AsType の第2戻り値で済みます。値を捨てて bool だけ受ける。番兵値を別途用意する必要はありません。

if _, ok := errors.AsType[*ValidationError](err); ok {
    // ValidationError がツリーのどこかにある
}

まとめ

errors.AsTypeerrors.As の置き換え先として素直に使えます。型パラメータで取り出す型を指定し、戻り値で受け取る。要点を並べます。

  • AsType[E error](err error) (E, bool)。ポインタ渡しが消え、変数スコープが if 内で閉じる
  • 型の誤用は実行時panicからコンパイルエラーに移る。reflect を通らないぶん速い
  • ラップの木は深さ優先で辿り、最初に一致した型を返す
  • error を実装しないインターフェースを的にする場面では As が残る
  • 値の同一性は errors.Is、型の取り出しは errors.AsType で分ける

Go 1.26 に上げたら、新規コードの AsAsType から書き始めてよい。既存の As も、error 型を的にしている箇所は機械的に置き換えられます。

よくある疑問

Q. Go 1.25以前でも使えますか。
使えません。errors.AsType は Go 1.26 で追加されました。go.modgo ディレクティブが 1.26 以上である必要があります。

Q. 既存の errors.As は全部消すべきですか。
急ぐ必要はありません。error 型を的にしている AsAsType に置き換えられます。非errorインターフェースを渡している箇所はそのまま残します。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次