Getting Started with Go, Part 2: Structs, Interfaces & Errors
Part 1 left us with jobs as bare strings and a switch that guessed their priority from a prefix. That's fine for a toy, but it doesn't survive contact with a second requirement — retries, a real priority field, more than one way to process a job. This part gives the processor real types.
Structs instead of stringly-typed data
A struct is a named collection of fields — no constructors, no access modifiers, just a type declaration and a literal to build one:
type Job struct {
Name string
Priority string
Retries int
}
job := Job{Name: "urgent-deploy", Priority: "HIGH", Retries: 3}
Field names starting with a capital letter are exported (visible outside the package); lowercase is package-private. That capitalization is the access modifier — there's no public/private keyword doing it separately, which trips up people coming from Java or Ruby the first time they see it.
Interfaces: implemented implicitly
Go interfaces describe behavior, not identity, and a type satisfies one just by having the right methods — there's no implements keyword, no declared relationship at all:
type Processor interface {
Process(j Job) error
}
Anything with a Process(Job) error method is a Processor, full stop, even if it was written before the interface existed. That's the practical payoff: you can define a small interface next to the code that consumes it, and existing types satisfy it retroactively as long as the method shape matches. Compare that to defining a hierarchy up front and forcing every implementer to declare it — Go's version has a lot less ceremony and a lot less coupling between the interface and whoever implements it.
Composition over inheritance
Go doesn't have class inheritance at all. What it has instead is embedding — putting one struct inside another without a field name, which promotes the embedded type's fields and methods onto the outer type:
type Logger struct {
Prefix string
}
func (l Logger) Log(msg string) {
fmt.Printf("[%s] %s\n", l.Prefix, msg)
}
type Worker struct {
Logger // embedded, not a named field
ID int
}
w := Worker{Logger: Logger{Prefix: "worker-1"}, ID: 1}
w.Log("started") // Worker didn't define Log — Logger did
Worker didn't declare Log — it got it for free by embedding Logger. This is deliberately less powerful than inheritance: there's no method overriding, no super, no diamond-shaped ambiguity to reason about. If you want that field's behavior, you embed it; if you don't, you compose a regular named field instead. Composition is the only option, which is a large part of why Go codebases tend to have shallow, boring type hierarchies instead of deep inheritance chains.
Errors are values, not exceptions
Go has no try/catch. An error is just a value satisfying a one-method interface:
type error interface {
Error() string
}
The convention — not a language rule, just an convention everyone follows — is that any function that can fail returns (result, error), and callers check the error immediately:
if err != nil {
return err
}
That's the "boring" idiomatic form, and yes, you'll type it a lot. The payoff is that failure is visible in every function signature and every call site — you can't silently skip past an error the way a forgotten catch block lets you skip past an exception.
For our own errors, you build a real type implementing Error() string, which lets you attach context an inspecting caller can use:
type ProcessError struct {
Job string
Err error
}
func (e *ProcessError) Error() string {
return fmt.Sprintf("processing %q: %v", e.Job, e.Err)
}
func (e *ProcessError) Unwrap() error {
return e.Err
}
That Unwrap method is what makes errors.Is and errors.As work — they walk the chain of wrapped errors looking for a match, instead of you manually unwrapping nested error types by hand:
var procErr *ProcessError
if errors.As(err, &procErr) {
fmt.Println("job that failed:", procErr.Job)
}
Putting it together
A Job struct, a Processor interface, and a custom error type that a caller can inspect with errors.As — this is the whole program, and it's a complete one, so Run actually executes it:
package main
import (
"errors"
"fmt"
"strings"
)
type Job struct {
Name string
Priority string
Retries int
}
type Processor interface {
Process(j Job) error
}
var ErrJobFailed = errors.New("job failed")
type ProcessError struct {
Job string
Err error
}
func (e *ProcessError) Error() string {
return fmt.Sprintf("processing %q: %v", e.Job, e.Err)
}
func (e *ProcessError) Unwrap() error {
return e.Err
}
type FlakyProcessor struct{}
func (FlakyProcessor) Process(j Job) error {
if strings.Contains(j.Name, "flaky") {
return &ProcessError{Job: j.Name, Err: ErrJobFailed}
}
fmt.Printf("processed %s (priority=%s)\n", j.Name, j.Priority)
return nil
}
func run(p Processor, jobs []Job) {
for _, j := range jobs {
err := p.Process(j)
if err == nil {
continue
}
var procErr *ProcessError
if errors.As(err, &procErr) {
fmt.Printf("retrying %s after: %v\n", procErr.Job, procErr.Err)
continue
}
fmt.Println("unexpected error:", err)
}
}
func main() {
jobs := []Job{
{Name: "urgent-deploy", Priority: "HIGH", Retries: 3},
{Name: "flaky-migration", Priority: "NORMAL", Retries: 1},
{Name: "cleanup", Priority: "LOW", Retries: 0},
}
run(FlakyProcessor{}, jobs)
}
run takes a Processor interface, not a concrete FlakyProcessor — that's what lets Part 3 swap in a completely different implementation (a concurrent worker pool) without touching this function's signature at all.
Further Reading
- Effective Go: Interfaces and other types
- Go blog: Error handling and Go
- Go blog: Working with Errors in Go 1.13 — the
errors.Is/errors.As/Unwrapstory