Custom Errors & errors.Is/As

Reviewed & published by Brayan K

Because error is just an interface, you can create sentinel errors and custom error types, wrap them with %w to add context, and then inspect the chain with errors.Is and errors.As to react to exactly what went wrong.

Part of the free Go course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

1️⃣ Sentinel errors and errors.Is

A sentinel is a package-level error you compare against, created with errors.New. When you return it from deep in the call stack, wrap it with fmt.Errorf("...: %w", ErrNotFound) to add context while keeping it detectable. Callers use errors.Is, which walks the entire wrap chain — so it matches even through several layers.

package main

import (
    "errors"
    "fmt"
)

// A SENTINEL error: a package-level variable you compare against.
var ErrNotFound = errors.New("not found")

func lookup(id int) error {
    if id == 0 {
        // Wrap the sentinel with %w so callers can still detect it, while
        // adding context about WHERE it happened.
        return fmt.Errorf("lookup id=%d: %w", id, ErrNotFound)
    }
    return nil
}

func main() {
    err := lookup(0)
    fmt.Println("error:", err)

    // errors.Is walks the whole wrap chain looking for the sentinel —
    // so it matches even though err is a wrapped fmt.Errorf value.
    if errors.Is(err, ErrNotFound) {
        fmt.Println("matched ErrNotFound? true")
    }

    // A non-error case: nil never matches.
    fmt.Println("lookup(5) is nil?", lookup(5) == nil)
}

2️⃣ Custom error types and errors.As

When the caller needs data about the failure — a field name, a code — define a struct and give it an Error() string method; that's all it takes to satisfy the error interface. To pull it back out of a wrap chain, use errors.As, which finds an error of that type and assigns it so you can read its fields.

package main

import (
    "errors"
    "fmt"
)

// A CUSTOM error type carries structured data. It satisfies the error
// interface by implementing Error() string.
type ValidationError struct {
    Field string
    Rule  string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("field %q failed rule %q", e.Field, e.Rule)
}

func validateAge(age int) error {
    if age < 0 {
        return &ValidationError{Field: "age", Rule: "non-negative"}
    }
    return nil
}

func main() {
    // Wrap it so there's context around the structured error.
    err := fmt.Errorf("validating user: %w", validateAge(-1))
    fmt.Println("error:", err)

    // errors.As finds an error of a SPECIFIC TYPE in the chain and assigns
    // it, so you can read its fields.
    var ve *ValidationError
    if errors.As(err, &ve) {
        fmt.Printf("structured: field=%s rule=%s\n", ve.Field, ve.Rule)
    }
}

3️⃣ Reacting to different failures

Real code uses several sentinels and routes on which one occurred — for example mapping internal errors to HTTP status codes. A switch with errors.Is cases reads cleanly and still works through wrapping.

package main

import (
    "errors"
    "fmt"
)

var (
    ErrNotFound   = errors.New("not found")
    ErrPermission = errors.New("permission denied")
)

// openDoc returns different sentinels for different failures, wrapped with
// context. The caller decides what to do based on WHICH sentinel.
func openDoc(name string) error {
    switch name {
    case "missing.txt":
        return fmt.Errorf("open %s: %w", name, ErrNotFound)
    case "secret.txt":
        return fmt.Errorf("open %s: %w", name, ErrPermission)
    default:
        return nil
    }
}

func classify(name string) string {
    err := openDoc(name)
    switch {
    case err == nil:
        return "ok"
    case errors.Is(err, ErrNotFound):
        return "404 — " + err.Error()
    case errors.Is(err, ErrPermission):
        return "403 — " + err.Error()
    default:
        return "500 — " + err.Error()
    }
}

func main() {
    for _, n := range []string{"report.txt", "missing.txt", "secret.txt"} {
        fmt.Printf("%-12s -> %s\n", n, classify(n))
    }
}

🎯 Your Turn

Wrap a sentinel and then detect it. Fill in the two blanks marked ___, then run it.

package main

import (
    "errors"
    "fmt"
)

var ErrEmpty = errors.New("empty input")

func parse(s string) error {
    if s == "" {
        // 1) Wrap ErrEmpty with %w so errors.Is can still find it
        return fmt.Errorf("parse: %w", ___) // 👉 ErrEmpty
    }
    return nil
}

func main() {
    // 🎯 YOUR TURN — fill in the blanks marked ___
    err := parse("")

    // 2) Detect the sentinel anywhere in the chain
    if errors.___(err, ErrEmpty) { // 👉 Is
        fmt.Println("input was empty")
    }

    // ✅ Expected output:
    //    input was empty
}

Common Errors (and the fix)

❌ Comparing wrapped errors with == — fails after %w.

❌ Wrapping with %v when you meant to keep the error detectable.

✅ Use %w so Is/As can still see the original.

❌ Passing a value instead of a pointer to errors.As — it panics ("target must be a non-nil pointer").

✅ Declare var e *MyError and call errors.As(err, &e).

❌ Type-asserting the error (err.(*MyError)) — misses it when wrapped.

✅ Use errors.As, which unwraps the chain.

🧠 Quick Recall

e := fmt.Errorf("x: %w", ErrA); e == ErrA ?

false — e is a new wrapper. But errors.Is(e, ErrA) is true, because it unwraps the chain.

need the error's Code field — Is or As?

errors.As — it extracts the typed error so you can read fields. errors.Is only answers yes/no for a value.

🎯 Mini-Challenge: Extract a Status Code

Make fetch return a wrapped *HTTPError with code 503, then let the errors.As block in main pull out and print just the code.

package main

import (
    "errors"
    "fmt"
)

// HTTPError is a custom error carrying a status code.
type HTTPError struct {
    Code int
    Msg  string
}

func (e *HTTPError) Error() string {
    return fmt.Sprintf("http %d: %s", e.Code, e.Msg)
}

func fetch(url string) error {
    // 🎯 MINI-CHALLENGE
    // Return an *HTTPError{Code: 503, Msg: "service unavailable"} WRAPPED
    // with fmt.Errorf("fetch %s: %w", url, ...). Then in main use errors.As
    // to pull out the *HTTPError and print just its Code.
    //
    // ✅ Expected output:
    //    status code: 503
    return nil
}

func main() {
    err := fetch("https://example.test")
    var he *HTTPError
    if errors.As(err, &he) {
        fmt.Println("status code:", he.Code)
    }
    // your job: make fetch return a wrapped *HTTPError so the line above runs
}

🎉 Lesson Complete!

Practice quiz

What is the error interface in Go?

  • interface{ Error() string }
  • interface{ String() string }
  • interface{ Err() error }
  • a built-in struct type

Answer: interface{ Error() string }. Any type with an Error() string method satisfies the error interface.

Which verb wraps an error so errors.Is can still find it through the chain?

  • %v
  • %s
  • %w
  • %e

Answer: %w. %w in fmt.Errorf keeps a link to the original error; %v would flatten it to text and lose the link.

After err := fmt.Errorf("x: %w", ErrNotFound), what is err == ErrNotFound?

  • true
  • false
  • a compile error
  • a panic

Answer: false. Wrapping creates a new wrapper value, so == is false even though errors.Is(err, ErrNotFound) is true.

Which function detects a specific sentinel error value anywhere in the wrap chain?

  • errors.As
  • errors.Is
  • errors.Unwrap
  • errors.New

Answer: errors.Is. errors.Is walks the chain comparing against a value; use it for sentinels.

Which function extracts a typed error so you can read its fields?

  • errors.Is
  • errors.New
  • errors.As
  • fmt.Errorf

Answer: errors.As. errors.As finds an error of a given TYPE in the chain and assigns it to your variable.

How do you create a sentinel error?

  • var ErrX = errors.New("...")
  • var ErrX = fmt.Sprintf("...")
  • type ErrX error
  • var ErrX = panic("...")

Answer: var ErrX = errors.New("..."). A sentinel is a package-level value made with errors.New that callers compare against.

errors.As(err, target) requires target to be...

  • a value of the error type
  • a non-nil pointer to a variable of the error type
  • a string
  • nil

Answer: a non-nil pointer to a variable of the error type. Pass a pointer like &ve where var ve *MyError; a value (or nil) makes As panic.

Why use errors.Is instead of == for a wrapped sentinel?

  • == is slower
  • == fails after wrapping; Is unwraps the chain
  • == is not allowed on errors
  • they are identical

Answer: == fails after wrapping; Is unwraps the chain. After %w the value is a new wrapper, so == is false; errors.Is unwraps step by step and matches.

If you wrap with %v instead of %w, errors.Is(err, ErrX) will...

  • still return true
  • return false because the link is lost
  • panic
  • not compile

Answer: return false because the link is lost. %v only prints text and drops the chain link, so errors.Is can no longer find the original.

Is or As — you need the error's Code field?

  • errors.Is
  • errors.As
  • either works
  • neither

Answer: errors.As. errors.As extracts the typed error so you can read fields; errors.Is only gives a yes/no for a value.

Continue this course

Frequently asked questions

Why is error an interface, and what does that buy me?

error is just interface{ Error() string }. Because it's an interface, ANY type with an Error() string method is an error — so you can attach structured data (a status code, a field name, a retry hint) to your errors and still pass them around as a plain error. Callers can later inspect the concrete type with errors.As when they need those details.

What is a sentinel error and when should I use one?

A sentinel is a package-level error value, like var ErrNotFound = errors.New("not found"), that callers compare against to detect a specific condition. Use one when the only thing the caller needs to know is 'this particular thing happened' — no extra data. Detect it with errors.Is, never with ==, so it still matches after wrapping.

What does wrapping with %w actually do?

fmt.Errorf("context: %w", err) creates a new error that adds your message AND keeps a link to the original. That link is what errors.Is and errors.As follow when they walk the chain. Use %w (not %v) whenever you want the caller to still be able to detect or extract the underlying error; use %v if you deliberately want to hide it.

What's the difference between errors.Is and errors.As?

errors.Is(err, target) asks 'is this specific error VALUE somewhere in the chain?' — used for sentinels. errors.As(err, &target) asks 'is there an error of this TYPE in the chain?' and, if so, assigns it to your variable so you can read its fields — used for custom error types. Rule of thumb: Is compares against a value, As extracts a type.

Should Error() be on a pointer or value receiver?

Usually a pointer receiver — func (e *MyError) Error() string — because you typically return &MyError{...} and errors.As needs a pointer to a pointer (var me *MyError; errors.As(err, &me)). Be consistent: if Error() is on *MyError, then *MyError is the error type, and you must return the pointer, not a value.

Why not just compare errors with == after wrapping?

Once you wrap an error with %w, the value is a new fmt.wrapError, so err == ErrNotFound is false even though ErrNotFound is inside. errors.Is unwraps the chain step by step and compares at each level, so it correctly reports a match. Always use errors.Is for sentinels and errors.As for types instead of == and type assertions.

Related lessons