Skip to Content
Course content

151: Custom Error Types Revisited

Click on the "Edit" button in the top corner of the screen to edit your slide content.

We've talked about custom errors before, but usually, it's in a vacuum. You create a struct, give it an Error() method, and you're done. But in a real-world project—one with multiple layers of service calls and database wrappers—that simple approach usually falls apart the moment you start wrapping errors to add context.

I want to show you exactly where I usually trip up and how I fixed it. Let's imagine we're building a configuration loader for a system that reads a .toml file. We want to know not just that it failed, but specifically where it failed so we can tell the user exactly which line to fix.

Just a simple struct, right?

My first instinct is always to just define a custom type. It's clean and typed. Let's try this:

type ConfigError struct {
    Line int
    Msg  string
}

func (e *ConfigError) Error() string {
    return fmt.Sprintf("config error at line %d: %s", e.Line, e.Msg)
}

func LoadConfig() error {
    // Simulating a parse error on line 42
    return &ConfigError{Line: 42, Msg: "unexpected character '@'"}
}

Now, if I call LoadConfig() and use a type assertion, everything is great. I can grab that Line number and do something smart with it. But here is where the "real world" hits. In a real app, I wouldn't just return the error; I'd wrap it to provide context about which file was being loaded.

The moment it all breaks

Let's wrap that error and see what happens to our ability to inspect it:

func MainProcess() error {
    err := LoadConfig()
    if err != nil {
        // I'm adding context here using %w
        return fmt.Errorf("failed to initialize system: %w", err)
    }
    return nil
}

func main() {
    err := MainProcess()
    
    // Attempting the old-school type assertion
    if cfgErr, ok := err.(*ConfigError); ok {
        fmt.Println("Found the line number:", cfgErr.Line)
    } else {
        fmt.Println("Could not extract ConfigError!")
    }
}

If you run this, it prints "Could not extract ConfigError!". Why? Because fmt.Errorf with %w doesn't return a *ConfigError; it returns a private wrapper type that contains the *ConfigError. My type assertion is looking for the wrapper to be the error, but the error is actually buried inside it.

Peeling back the layers with As

This is why we have to stop using type assertions for errors. I had to learn this the hard way after a few production bugs. The errors.As function is designed specifically for this "nested" scenario. It recursively unwraps the error chain until it finds a match for the type you're looking for.

Let's adjust the main function:

func main() {
    err := MainProcess()

    var cfgErr *ConfigError
    if errors.As(err, &cfgErr) {
        fmt.Println("Found the line number:", cfgErr.Line)
    } else {
        fmt.Println("Still no luck.")
    }
}

Now it works. errors.As sees the wrapper, asks "Are you a *ConfigError?", gets a "no", then asks the wrapped error "Are you a *ConfigError?", and finally gets a "yes". It then populates the cfgErr variable with the pointer it found.

Adding a layer of intentionality

One last thing. Sometimes you don't just want to know the type of the error, but whether it matches a specific category of error, regardless of the specific instance. This is where errors.Is comes in, but if you want your custom type to be "is-able" without matching a specific instance, you can implement an Is(target error) bool method on your struct.

For example, maybe I want all ConfigError types to be considered "Temporary" if the line is 0 (meaning a generic IO issue). I can add this to my struct:

func (e *ConfigError) Is(target error) bool {
    t, ok := target.(*ConfigError)
    if !ok {
        return false
    }
    // If the target is a "generic" ConfigError (line 0), 
    // and this error is also generic, they match.
    return e.Line == 0 && t.Line == 0
}

By doing this, I've moved the logic of "what defines this error" inside the error type itself, rather than leaking that logic into my if/else blocks in main. It makes the error type a first-class citizen that knows how to describe its own identity.




📋 Practical Task

Implementing a Detailed API Validation Error Handler

You are building a user registration system. You need to create a custom error type called ValidationError that tracks which specific field failed (e.g., "email", "password") and the reason for the failure.

Your requirements:

  • Create a ValidationError struct with Field and Reason strings.
  • Implement the Error() method for this struct.
  • Create a function ValidateUser(email string) error that returns a wrapped ValidationError if the email is empty.
  • In your main function, call ValidateUser("").
  • Use errors.As to extract the ValidationError and print a message specifically identifying which field caused the problem.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.