Error Handling and Project Structure in Go

I walk through Go's error-as-value pattern — errors.New, fmt.Errorf with %w, errors.Is, custom error types — and show how cmd/, internal/, and pkg/ structure a real Go project.

Error Handling and Project Structure in Go

JavaScript’s try/catch lets errors travel quietly. You call a function that throws, forget to wrap it, and the program keeps running until it doesn’t. Go was designed to make that impossible to do quietly. Every function that can fail returns an error as an explicit second value. You either handle it, assign it to the blank identifier _ and accept the risk, or the compiler refuses to build.

That constraint sounds tedious. What you discover after a while is that it changes how readable Go code is. Every failure path is visible at the call site. When you combine that with %w wrapping and errors.Is, you can follow an error from where a caller inspects it all the way back to where the original problem occurred — without running the program.

I’m going to walk through the full picture: the error interface, errors.New, fmt.Errorf with %w, sentinel errors, custom error types, and the project layout conventions (cmd/, internal/, pkg/) that tie it all together.

Series: Part 4 of 4 — finale. Part 1: Go for JavaScript Developers: Getting Started · Part 2: Goroutines and Channels Explained with Examples · Part 3: Building a REST API in Go with Gin

On this page

The error interface: one method, no magic

The error type in Go is a built-in interface with a single method:

type error interface {
    Error() string
}

Any type that implements Error() string satisfies it. There is no base exception class, no hierarchy, no runtime stack capture by default. As the Go Blog explains, this simplicity is the whole point — you get a printable message and you can add any other behavior you need.

This is why Go functions that can fail return two values:

func readConfig(path string) (Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return Config{}, err
    }
    // parse and return
}

The caller receives both the result and the signal. The pattern if err != nil is Go’s version of checking whether something went wrong. It is verbose compared to try/catch — and that verbosity is deliberate. Every failure path is visible at the call site, not hidden up the call stack in a catch block somewhere else in the file.

If you’ve worked with Node.js callbacks, you’ve seen a similar intent in the (err, result) convention. Go enforces that intent at the type level instead of relying on the discipline of whoever wrote the function.

Building errors with context: errors.New and fmt.Errorf

The simplest way to create an error is errors.New:

import "errors"

var ErrTimeout = errors.New("connection timed out")

errors.New returns a value that satisfies the error interface. The string describes what went wrong. Simple, but it loses the context of where things failed.

fmt.Errorf gives you formatted strings, so you can include the value that caused the problem:

func parsePort(raw string) (int, error) {
    port, err := strconv.Atoi(raw)
    if err != nil {
        return 0, fmt.Errorf("parsePort: %q is not a valid integer", raw)
    }
    if port < 1 || port > 65535 {
        return 0, fmt.Errorf("parsePort: %d is outside the valid range 1–65535", port)
    }
    return port, nil
}

The parsePort: prefix follows a convention Effective Go makes explicit: error strings should identify their origin. A message like parsePort: "abc" is not a valid integer stays useful even when it surfaces three function calls away from where parsePort was called.

Compare that to JavaScript: throw sends the error up the stack regardless of whether any particular caller was ready for it. The Node.js centralized error handling pattern shows one approach to managing that. In Go, return 0, fmt.Errorf(...) makes the error a value the caller actively receives — there is no sneaking past each other.

Wrapping errors with %w and inspecting chains

Go 1.13 added the %w verb to fmt.Errorf. It wraps an error — the outer error contains the inner one and exposes it through an Unwrap method.

func openConfig(path string) error {
    _, err := os.ReadFile(path)
    if err != nil {
        return fmt.Errorf("openConfig: %w", err)
    }
    return nil
}

When a caller receives the error from openConfig, they see openConfig: open config.yaml: no such file or directory. The original *os.PathError from os.ReadFile is still inside it, reachable through the chain.

Working with Errors in Go 1.13 describes two functions that traverse this chain:

errors.Is checks whether a specific error appears anywhere in the chain:

err := openConfig("config.yaml")
if errors.Is(err, os.ErrNotExist) {
    // file doesn't exist — create defaults and continue
}

This is more reliable than err == os.ErrNotExist because it works through wrapping. Each layer is unwrapped until a match is found or the chain is exhausted.

errors.As checks whether any error in the chain matches a specific type, and sets a target variable to it:

var pathErr *os.PathError
if errors.As(err, &pathErr) {
    fmt.Println("failed on path:", pathErr.Path)
}

This lets you extract structured information from a wrapped error — a file path, an HTTP status code, a database query — without losing the outer context message.

Check this before moving on

  • You can explain why errors.Is(err, target) is better than err == target when wrapping is involved
  • You understand that %w makes the wrapped error inspectable; %v only embeds the text
  • You know that errors.As extracts a concrete type from the chain, not just matches on equality

Sentinel errors and custom error types

A sentinel error is a package-level variable you define once and compare against elsewhere:

var ErrNotFound = errors.New("not found")

func findUser(id int) (*User, error) {
    if id == 0 {
        return nil, ErrNotFound
    }
    // ...
}

Callers check for it with errors.Is(err, ErrNotFound). The Go standard library uses this pattern extensively — io.EOF, sql.ErrNoRows, and os.ErrNotExist are all sentinel errors. They give you a named condition to check without needing a specific type.

When a string message is not enough and you need callers to inspect fields on the error itself, define a custom type:

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Message)
}

The Error() method satisfies the error interface. The caller uses errors.As to retrieve the *ValidationError and inspect Field or Message directly:

var ve *ValidationError
if errors.As(err, &ve) {
    respondWithBadRequest(w, ve.Field, ve.Message)
}

The Go Blog’s example shows the same pattern with json.SyntaxError, which exposes an Offset field giving the byte position of the parse failure. The error string alone prints the message; the type gives the caller structured access to the data.

When not to wrap: the decision %w puts on you

The %w verb was an intentional addition, and the Go team’s guidance on it is precise: wrap an error when you want callers to be able to inspect the underlying error; don’t wrap when doing so exposes an implementation detail you don’t want to commit to.

Working with Errors in Go 1.13 illustrates this clearly. If a LookupUser function uses database/sql internally and returns sql.ErrNoRows wrapped with %w, callers can now write errors.Is(err, sql.ErrNoRows). That makes sql.ErrNoRows part of your package’s contract. If you ever swap the database layer, callers that checked for it break.

The practical rule: use %w when the wrapped error is meaningful to the caller and you’re prepared to keep returning it. Use %v when you want the text in the message but don’t want the error value itself to be inspectable downstream.

This forces a decision JavaScript’s exception model never required. With try/catch, whatever error bubbles up is what callers see — there is no equivalent moment where you consciously choose what part of an error’s identity you’re exposing.

Project layout: what cmd/, internal/, and pkg/ actually mean

Go doesn’t enforce a project layout beyond the module and package rules. But official guidance on organizing a Go module describes conventions that almost every non-trivial project follows.

cmd/ holds the entry points — each subdirectory is a separate binary:

cmd/
  api-server/
    main.go
  migrate/
    main.go

go install github.com/yourname/project/cmd/api-server builds and installs that binary. Separating commands into cmd/ keeps each main.go thin and pushes the actual logic into packages where it can be tested and reused.

internal/ contains packages that must not be imported outside the module. Go enforces this at the compiler level — any code outside the parent of internal/ that tries to import from it will fail to build. This is the right home for database logic, authentication helpers, and domain models you don’t want external packages depending on.

internal/
  auth/
    auth.go
  store/
    store.go

If you’ve relied on documentation or convention in a Node.js project to communicate what’s internal, internal/ is the formal version of that instinct — enforced by the compiler.

pkg/ holds packages intended for external use. It’s optional — servers and applications that don’t publish importable packages skip it entirely. For a server project — which describes most of what this series has covered — the layout frequently simplifies to:

cmd/
  api-server/
    main.go
internal/
  auth/
  store/
  model/
go.mod
go.sum

The cmd/api-server/main.go wires everything together; the internal/ packages hold the logic. Tests live next to the files they cover. The compiler knows where everything is — no test-runner configuration needed.

Try this

  1. mkdir myapp && cd myapp && go mod init myapp
  2. Create internal/greet/greet.go with a function that returns a greeting and an error when the name is empty.
  3. Create cmd/hello/main.go that calls it and handles the error with if err != nil.
  4. Run go build ./....

Expected result: The build succeeds. Trying to import myapp/internal/greet from a different module fails at compile time with a message about the internal path restriction.

Treat the error as a value, not an interruption

The clearest way I can frame the shift from JavaScript to Go: errors in Go are not events that interrupt execution. They are values that functions return alongside their results. You handle them where they occur, with ordinary conditionals, not with a catch block that may or may not be nearby.

That changes how you read code. In a Go function, every if err != nil block is a failure branch you can follow directly. When you layer in %w wrapping and errors.Is, you can also trace an error backward through a chain — seeing exactly what context each layer added and whether the root cause matches what you expected.

The project layout gives the same explicitness to the structure of a program. cmd/ makes entry points obvious. internal/ declares what’s private without relying on a convention someone might ignore. The two reinforce each other: clear ownership of code, clear ownership of failure paths.

If you’ve followed all four parts, you’ve covered the type system, goroutines and channels, REST APIs with Gin, and now error handling with a real project layout — a foundation for writing Go that other developers can read, debug, and extend without surprises.

Sources