Go for Developers: Errors, Interfaces and Methods

Introduction
In the previous part, we explored Defined Types, Pointers, and Functions. We learned how Go manages memory via Escape Analysis, how function values are represented as pointers to funcval structs, and how defined types provide zero-cost abstractions.
This article starts with Methods, building naturally on the Defined Types we just discussed, before moving on to Interfaces and Errors.
Methods
As we already know, Go does not have classes in the traditional Object-Oriented sense. Instead, behavior is attached to Defined Types using methods.
Syntax
Unlike languages where the current object is available inside a method as this or self without appearing in the function signature, Go requires the explicit declaration of the receiver's name and type in a special argument list between the func keyword and the method name.
go1// 1. A Defined Type2type Counter int34// 2. The Method5// (c Counter) is the receiver block: 'c' is the receiver variable, 'Counter' is the receiver type.6func (c Counter) Value() int {7 return int(c)8}910// 3. Method with a pointer receiver11func (c *Counter) Increment() {12 *c++13}
Value Receivers vs. Pointer Receivers
Choosing between a value receiver (T) and a pointer receiver (*T) is one of the most important decisions when designing Go types:
- Use a Pointer Receiver if the receiver must be modified (like
Incrementabove), or if the struct is very large and copying it would be expensive. - Use a Value Receiver if the type is small (like an int or a small struct) and logically acts as an immutable value. The standard library's
time.Timeis a perfect example: it represents a fixed point in time, so its methods return new values rather than mutating the original instance.
You do not need to manually take the address of a value to call a pointer receiver method on it. If c is declared as a Counter value (not a pointer), Go automatically rewrites c.Increment() to (&c).Increment(), but only when c is addressable. This means you rarely need to think about this distinction at the call site. The choice of receiver type is a concern of the method definition, not the caller.
Methods Under the Hood
At the machine level, methods don't actually belong to the struct in memory. Conceptually, a method is a regular function where the receiver is passed as the first argument.
go1// What you write:2func (c *Counter) Increment() { *c++ }34// How the compiler views it (logically):5func Counter_Increment(c *Counter) { *c++ }
As discussed in the previous article, when you declare a method on a defined type, the compiler appends an UncommonType block to the abi.Type descriptor. This block contains an array of method metadata (names, signatures, and function pointers). This allows the reflect package and interfaces to discover what methods a type has at runtime, without adding any memory overhead to the struct instances themselves.
Note: Don't mix receiver types. If any one method of a type takes a pointer receiver, all methods on that type should take a pointer receiver, even if they don't strictly need to mutate the state. Mixing value and pointer receivers on the same struct can lead to confusion and bugs when implementing interfaces.
Interfaces
In Go, a type implements an interface if it has all the required methods. An interface defines a method set: the exact signatures a type must implement. This method set acts as a behavioral contract: any type that satisfies it is guaranteed to behave in the expected way, regardless of its underlying implementation.
Syntax
An interface is defined using the interface keyword.
For example, any concrete type that implements a Write method with this exact signature can be assigned directly to a variable of type Writer:
go1type Writer interface {2 Write(p []byte) (n int, err error)3}45type ConsoleWriter struct{}67// ConsoleWriter implicitly implements Writer8func (c ConsoleWriter) Write(p []byte) (n int, err error) {9 return os.Stdout.Write(p)10}1112var w Writer = ConsoleWriter{}
In Go, any (an alias for interface{}) is an interface with no methods, meaning every type implements it. Use it when a function or data structure needs to accept values of any type.
Interfaces Under the Hood: iface and eface
An interface is a fat pointer occupying two machine words (16 bytes on a 64-bit system). The runtime uses two different structs for interfaces: eface (empty interfaces) and iface (interfaces with methods).
When multiple types implement the same interface and a variable can hold any of them, how does Go know at runtime which concrete Write method to call? When you call a method on an interface variable, Go needs to resolve which concrete implementation to execute at runtime.
eface (The Empty Interface)
When you assign a value to an any (or interface{}), it is stored in an eface struct:
go1type eface struct {2 _type *_type // Pointer to the underlying abi.Type descriptor3 data unsafe.Pointer // Pointer to the actual data payload4}
This is how functions like fmt.Println(args ...any) know the underlying type of the passed argument: they inspect the _type pointer.
iface (Interfaces with Methods)
When you assign a value to a typed interface (like Writer or error), it is stored in an iface struct:
go1type iface struct {2 tab *itab // Pointer to the Interface Table3 data unsafe.Pointer // Pointer to the actual data payload4}
Notice the _type is replaced by an itab (Interface Table). The itab is a highly optimized struct that contains:
- The
abi.Typeof the interface itself (e.g.,Writer). - The
abi.Typeof the concrete data (e.g.,ConsoleWriter). - An array of function pointers.
How this itab is generated depends on how the interface is assigned:
1. Compile-Time Generation: If both types are statically known, the compiler pre-generates the itab. When you explicitly assign var w Writer = ConsoleWriter{}, the compiler calculates the method mappings and bakes the complete itab directly into the binary. This means there is zero computation required at runtime.
2. Runtime Generation: However, if the types are dynamic (like asserting an interface from an any value), the Go runtime must compute the itab on the fly at the exact moment of evaluation. To keep this fast, the runtime caches these newly computed itabs in a global hash table so they only need to be calculated once.
Regardless of how the itab is generated, when w.Write() is called, the CPU executes Dynamic Dispatch:
text1It looks at the iface's tab =>2It fetches the function pointer for Write from the itab array =>3It jumps to that machine code, passing the data pointer as the receiver.
Because itabs are either embedded directly into the binary or cached globally by the runtime, interface method calls in Go are incredibly fast, requiring just an indirect function call via pointer lookup.
Errors
Go treats errors as standard, plain values that describe an abnormal state. They can be returned, inspected, and handled like any regular value.
In the source code, an error is defined as a built-in interface:
go1type error interface {2 Error() string3}
Syntax
It is idiomatic in Go to return the error as the last return value of a function. If the operation succeeds, the result is returned alongside nil for the error. Because errors are values, they are handled using standard if statements.
go1func divide(a, b float64) (float64, error) {2 if b == 0 {3 return 0, errors.New("cannot divide by zero")4 }5 return a / b, nil6}78result, err := divide(10, 0)910if err != nil {11 fmt.Println("Failed:", err)12 return13}
Wrapping and Unwrapping Errors
The fmt.Errorf function with the %w verb wraps an error inside another error, creating an error chain to provide context.
go1func fetchUser(id int) error {2 err := queryDatabase(id)34 if err != nil {5 return fmt.Errorf("fetchUser failed for id %d: %w", id, err)6 }7 return nil8}
To inspect these error chains, Go provides the errors package:
errors.Is(err, target): Checks if any error in the chain matches a specific target error value (great for checking sentinel errors likesql.ErrNoRows).errors.As(err, &target): Finds the first error in the chain that matches the type oftarget, and if so, extracts it.errors.Join(errs...): Introduced in Go 1.20, this bundles multiple concurrent errors into a single error value.
Errors Under the Hood
Because error is an interface, it is represented in Go's runtime as an iface struct. This struct consists of two pointers: an itab (interface table) pointer that holds the concrete type's metadata and method dispatch table, and a data pointer that points to the actual concrete value in memory.
text1error variable → [ itab Pointer | Data Pointer ]
An interface is only truly nil if its itab pointer is nil.
If a typed nil pointer is returned as an error, the interface itself is not nil because it is successfully populated with type information.
go1func doWork() error {2 var customErr *MyCustomError = nil3 return customErr // The interface receives (Type=*MyCustomError, Data=nil)4}56err := doWork()7fmt.Println(err == nil) // FALSE! The interface has a type, so it is not nil.
Always return an explicit nil identifier when no error occurs, rather than a nil pointer of a custom type.
By combining Defined Types, Pointers, Methods, and Interfaces, Go allows you to build highly decoupled, testable systems without the rigid inheritance hierarchies found in traditional Object-Oriented languages.
Next article: Coming soon