Cristhian Villegas
Backend10 min read2 views

Structs, Methods and Interfaces in Go: Complete Guide

Structs, Methods and Interfaces in Go: Complete Guide

Go Logo

Introduction: custom types in Go

Welcome to part 6 of our Go course for beginners. In previous articles we learned how to use basic types and data collections. But in real-world applications we need to represent complex entities like users, products or vehicles, with multiple related properties.

Go does not have classes like Java or Python, but it has something equally powerful: structs, methods and interfaces. Together, these three concepts let you write organized, reusable and flexible code. In this article we will learn how Go approaches object-oriented programming in a unique and elegant way.

Structs: definition and initialization

A struct is a composite data type that groups fields (properties) under a single name. It is the equivalent of a class in other languages, but without inheritance or magic constructors.

Defining and creating structs

go
1package main
2
3import "fmt"
4
5// Define a struct
6type Person struct {
7    Name  string
8    Age   int
9    Email string
10}
11
12func main() {
13    // Method 1: initialize with named fields (recommended)
14    p1 := Person{
15        Name:  "Ana Lopez",
16        Age:   25,
17        Email: "[email protected]",
18    }
19    fmt.Println(p1) // {Ana Lopez 25 [email protected]}
20
21    // Method 2: initialize in order (not recommended, fragile)
22    p2 := Person{"Luis Garcia", 30, "[email protected]"}
23    fmt.Println(p2)
24
25    // Method 3: empty struct (fields get zero values)
26    var p3 Person
27    fmt.Println(p3)       // { 0 }
28    fmt.Println(p3.Name)  // "" (empty string)
29    fmt.Println(p3.Age)   // 0
30
31    // Access and modify fields
32    p3.Name = "Maria"
33    p3.Age = 28
34    fmt.Printf("%s is %d years old\n", p3.Name, p3.Age)
35}
Tip: Always use named field initialization (Name: "Ana") instead of positional. If you add a field to the struct later, positional code breaks but named code keeps working.

Pointers to structs

In Go, structs are passed by value (a copy is made). If you want to modify the original struct, you need a pointer:

go
1package main
2
3import "fmt"
4
5type Product struct {
6    Name  string
7    Price float64
8}
9
10func applyDiscount(p *Product, percent float64) {
11    p.Price = p.Price * (1 - percent/100)
12}
13
14func main() {
15    laptop := Product{Name: "Laptop Pro", Price: 1500.00}
16    fmt.Printf("Before: $%.2f\n", laptop.Price) // $1500.00
17
18    applyDiscount(&laptop, 10)
19    fmt.Printf("After: $%.2f\n", laptop.Price) // $1350.00
20
21    // Create with pointer directly
22    tablet := &Product{Name: "Tablet X", Price: 500.00}
23    applyDiscount(tablet, 20)
24    fmt.Printf("Tablet: $%.2f\n", tablet.Price) // $400.00
25}
Note: Go automatically dereferences pointers to structs. You can write p.Name instead of (*p).Name, which keeps the code cleaner.

Nested structs and anonymous fields (embedding)

Go allows you to nest structs inside others and use anonymous fields to achieve something similar to inheritance. This is called embedding and is one of Go's most powerful features.

go
1package main
2
3import "fmt"
4
5// Base struct
6type Address struct {
7    Street string
8    City   string
9    State  string
10}
11
12// Struct with named field (explicit composition)
13type Employee struct {
14    Name    string
15    Title   string
16    Address Address // named field
17}
18
19// Struct with anonymous field (embedding)
20type Customer struct {
21    Name  string
22    Email string
23    Address // anonymous field - structure is "inherited"
24}
25
26func main() {
27    // With named field: explicit access
28    emp := Employee{
29        Name:  "Carlos",
30        Title: "Developer",
31        Address: Address{
32            Street: "100 Reform Ave",
33            City:   "Mexico City",
34            State:  "CDMX",
35        },
36    }
37    fmt.Println(emp.Address.City) // Mexico City
38
39    // With embedding: direct access to fields
40    cust := Customer{
41        Name:  "Maria",
42        Email: "[email protected]",
43        Address: Address{
44            Street: "50 Moon St",
45            City:   "Guadalajara",
46            State:  "Jalisco",
47        },
48    }
49    // Direct access (Go "promotes" the fields)
50    fmt.Println(cust.City)         // Guadalajara
51    fmt.Println(cust.Address.City) // Guadalajara (also works)
52}
Tip: Embedding is not inheritance. It is composition. The embedded struct is not a parent type; its fields and methods are simply promoted to the containing struct. This is intentional in Go: composition over inheritance.

Methods: value receivers vs pointer receivers

In Go you can associate functions to a type through methods. A method is simply a function with a receiver that specifies which type it operates on.

Value receiver vs pointer receiver

go
1package main
2
3import "fmt"
4
5type Rectangle struct {
6    Width  float64
7    Height float64
8}
9
10// Value receiver: works with a COPY of the struct
11// Use when you only need to READ data
12func (r Rectangle) Area() float64 {
13    return r.Width * r.Height
14}
15
16// Value receiver: does not modify the original
17func (r Rectangle) Perimeter() float64 {
18    return 2 * (r.Width + r.Height)
19}
20
21// Pointer receiver: works with the ORIGINAL struct
22// Use when you need to MODIFY data
23func (r *Rectangle) Scale(factor float64) {
24    r.Width *= factor
25    r.Height *= factor
26}
27
28func main() {
29    rect := Rectangle{Width: 10, Height: 5}
30
31    fmt.Printf("Area: %.2f\n", rect.Area())         // 50.00
32    fmt.Printf("Perimeter: %.2f\n", rect.Perimeter()) // 30.00
33
34    // Scale modifies the original struct
35    rect.Scale(2)
36    fmt.Printf("After scaling: %v\n", rect)  // {20 10}
37    fmt.Printf("New area: %.2f\n", rect.Area()) // 200.00
38}
Rule of thumb: If the method modifies the struct, use a pointer receiver (*Type). If it only reads data, use a value receiver (Type). When in doubt, use a pointer receiver. Also, if any method on a type uses a pointer receiver, all methods should use one for consistency.

Interfaces: implicit implementation

An interface in Go defines a set of methods that a type must implement. The key difference from other languages is that implementation is implicit: you do not need to write implements or any special declaration.

go
1package main
2
3import (
4    "fmt"
5    "math"
6)
7
8// Define an interface
9type Shape interface {
10    Area() float64
11    Perimeter() float64
12}
13
14// Circle implements Shape (implicitly)
15type Circle struct {
16    Radius float64
17}
18
19func (c Circle) Area() float64 {
20    return math.Pi * c.Radius * c.Radius
21}
22
23func (c Circle) Perimeter() float64 {
24    return 2 * math.Pi * c.Radius
25}
26
27// Rectangle implements Shape (implicitly)
28type Rectangle struct {
29    Width  float64
30    Height float64
31}
32
33func (r Rectangle) Area() float64 {
34    return r.Width * r.Height
35}
36
37func (r Rectangle) Perimeter() float64 {
38    return 2 * (r.Width + r.Height)
39}
40
41// Function that accepts any Shape
42func printInfo(s Shape) {
43    fmt.Printf("Area: %.2f\n", s.Area())
44    fmt.Printf("Perimeter: %.2f\n", s.Perimeter())
45}
46
47func main() {
48    c := Circle{Radius: 5}
49    r := Rectangle{Width: 10, Height: 3}
50
51    fmt.Println("=== Circle ===")
52    printInfo(c)
53
54    fmt.Println("\n=== Rectangle ===")
55    printInfo(r)
56
57    // A slice of interfaces can hold different types
58    shapes := []Shape{
59        Circle{Radius: 3},
60        Rectangle{Width: 4, Height: 6},
61        Circle{Radius: 7},
62    }
63
64    fmt.Println("\n=== All shapes ===")
65    for _, s := range shapes {
66        fmt.Printf("Area: %.2f\n", s.Area())
67    }
68}
Note: If a type has all the methods that an interface defines, it automatically satisfies that interface. There is no need for an explicit declaration like class Circle implements Shape. This makes Go interfaces extremely flexible.

Empty interface and type assertions

The empty interface (interface{} or any in Go 1.18+) can hold any value. It is similar to Object in Java or any in TypeScript. To extract the concrete type, we use type assertions and type switches.

go
1package main
2
3import "fmt"
4
5func describe(i interface{}) {
6    // Type switch: determine the concrete type
7    switch v := i.(type) {
8    case int:
9        fmt.Printf("Integer: %d (double is %d)\n", v, v*2)
10    case string:
11        fmt.Printf("String: %q (length %d)\n", v, len(v))
12    case bool:
13        fmt.Printf("Boolean: %t\n", v)
14    case []int:
15        fmt.Printf("Slice of ints: %v (length %d)\n", v, len(v))
16    default:
17        fmt.Printf("Unknown type: %T = %v\n", v, v)
18    }
19}
20
21func main() {
22    describe(42)
23    describe("hello world")
24    describe(true)
25    describe([]int{1, 2, 3})
26    describe(3.14)
27
28    // Direct type assertion (with check)
29    var value interface{} = "Go is awesome"
30
31    text, ok := value.(string)
32    if ok {
33        fmt.Println("It is a string:", text)
34    }
35
36    // This would panic at runtime without the check
37    number, ok := value.(int)
38    if !ok {
39        fmt.Println("Not an integer, zero value:", number)
40    }
41}
Warning: Always use the value, ok := i.(Type) pattern instead of value := i.(Type). Without the second value, if the type does not match, the program panics and crashes abruptly.

Common standard library interfaces

Go has several standard interfaces that are fundamental to the ecosystem. Knowing them will make you much more productive.

Stringer and error

go
1package main
2
3import (
4    "errors"
5    "fmt"
6)
7
8// Implement fmt.Stringer to customize printing
9type Product struct {
10    Name  string
11    Price float64
12}
13
14func (p Product) String() string {
15    return fmt.Sprintf("%s ($%.2f)", p.Name, p.Price)
16}
17
18// Implement the error interface
19type ValidationError struct {
20    Field   string
21    Message string
22}
23
24func (e ValidationError) Error() string {
25    return fmt.Sprintf("validation error on '%s': %s", e.Field, e.Message)
26}
27
28func validateAge(age int) error {
29    if age < 0 {
30        return ValidationError{Field: "age", Message: "cannot be negative"}
31    }
32    if age > 150 {
33        return ValidationError{Field: "age", Message: "unrealistic value"}
34    }
35    return nil
36}
37
38func main() {
39    // Stringer: fmt.Println uses String() automatically
40    p := Product{Name: "Laptop", Price: 1299.99}
41    fmt.Println(p) // Laptop ($1299.99)
42
43    // error: handle custom errors
44    if err := validateAge(-5); err != nil {
45        fmt.Println("Error:", err)
46
47        // Check the specific error type
48        var ve ValidationError
49        if errors.As(err, &ve) {
50            fmt.Println("Field:", ve.Field)
51            fmt.Println("Detail:", ve.Message)
52        }
53    }
54
55    if err := validateAge(25); err == nil {
56        fmt.Println("Valid age")
57    }
58}

io.Reader and io.Writer

go
1package main
2
3import (
4    "fmt"
5    "io"
6    "os"
7    "strings"
8)
9
10func main() {
11    // strings.NewReader implements io.Reader
12    reader := strings.NewReader("Hello from a Reader!")
13
14    // os.Stdout implements io.Writer
15    // io.Copy connects a Reader with a Writer
16    bytesCopied, err := io.Copy(os.Stdout, reader)
17    if err != nil {
18        fmt.Println("Error:", err)
19        return
20    }
21    fmt.Printf("\n(%d bytes copied)\n", bytesCopied)
22}

Composition over inheritance

Go does not have class inheritance. Instead, it promotes composition: building complex types by combining simpler ones. This is an intentional design decision that avoids many problems of classical inheritance (such as the diamond problem).

go
1package main
2
3import "fmt"
4
5// Behaviors as small interfaces
6type Notifiable interface {
7    Notify(message string)
8}
9
10type Storable interface {
11    Save() error
12}
13
14// Base types with specific functionality
15type EmailNotifier struct {
16    Email string
17}
18
19func (n EmailNotifier) Notify(message string) {
20    fmt.Printf("Sending email to %s: %s\n", n.Email, message)
21}
22
23type DBStore struct {
24    Table string
25}
26
27func (d DBStore) Save() error {
28    fmt.Printf("Saving to table '%s'\n", d.Table)
29    return nil
30}
31
32// Compose a complex type with embedding
33type User struct {
34    Name string
35    EmailNotifier // embedding: User "inherits" Notify()
36    DBStore       // embedding: User "inherits" Save()
37}
38
39func main() {
40    u := User{
41        Name:          "Ana",
42        EmailNotifier: EmailNotifier{Email: "[email protected]"},
43        DBStore:       DBStore{Table: "users"},
44    }
45
46    // Promoted methods: used directly
47    u.Notify("Welcome to the system")
48    u.Save()
49
50    // Also satisfies the interfaces
51    var n Notifiable = u
52    n.Notify("Important reminder")
53
54    var s Storable = u
55    s.Save()
56}
Tip: Go's philosophy is: "define small interfaces with few methods and compose types using embedding". Interfaces with 1-2 methods are the norm in Go. This makes code more flexible and easier to test.

Practical example: geometric shape calculator

Let us build a complete program that combines structs, methods and interfaces to create a geometric shape calculator.

go
1package main
2
3import (
4    "fmt"
5    "math"
6    "sort"
7)
8
9// Interface that all shapes must satisfy
10type Shape interface {
11    Area() float64
12    Perimeter() float64
13    Name() string
14}
15
16// --- Circle ---
17type Circle struct {
18    Radius float64
19}
20
21func (c Circle) Area() float64 {
22    return math.Pi * c.Radius * c.Radius
23}
24
25func (c Circle) Perimeter() float64 {
26    return 2 * math.Pi * c.Radius
27}
28
29func (c Circle) Name() string {
30    return fmt.Sprintf("Circle (r=%.1f)", c.Radius)
31}
32
33// --- Rectangle ---
34type Rectangle struct {
35    Width  float64
36    Height float64
37}
38
39func (r Rectangle) Area() float64 {
40    return r.Width * r.Height
41}
42
43func (r Rectangle) Perimeter() float64 {
44    return 2 * (r.Width + r.Height)
45}
46
47func (r Rectangle) Name() string {
48    return fmt.Sprintf("Rectangle (%.1fx%.1f)", r.Width, r.Height)
49}
50
51// --- Triangle ---
52type Triangle struct {
53    Base   float64
54    Height float64
55    SideA  float64
56    SideB  float64
57    SideC  float64
58}
59
60func (t Triangle) Area() float64 {
61    return (t.Base * t.Height) / 2
62}
63
64func (t Triangle) Perimeter() float64 {
65    return t.SideA + t.SideB + t.SideC
66}
67
68func (t Triangle) Name() string {
69    return fmt.Sprintf("Triangle (b=%.1f, h=%.1f)", t.Base, t.Height)
70}
71
72// --- Functions that work with the interface ---
73func printReport(shapes []Shape) {
74    fmt.Println("====================================")
75    fmt.Println("   GEOMETRIC SHAPES REPORT")
76    fmt.Println("====================================")
77
78    totalArea := 0.0
79    for _, s := range shapes {
80        area := s.Area()
81        totalArea += area
82        fmt.Printf("\n> %s\n", s.Name())
83        fmt.Printf("  Area:      %.2f\n", area)
84        fmt.Printf("  Perimeter: %.2f\n", s.Perimeter())
85    }
86
87    fmt.Println("\n------------------------------------")
88    fmt.Printf("Total combined area: %.2f\n", totalArea)
89}
90
91func largestShape(shapes []Shape) Shape {
92    // Sort by area (largest first)
93    sort.Slice(shapes, func(i, j int) bool {
94        return shapes[i].Area() > shapes[j].Area()
95    })
96    return shapes[0]
97}
98
99func main() {
100    shapes := []Shape{
101        Circle{Radius: 5},
102        Rectangle{Width: 10, Height: 4},
103        Triangle{Base: 6, Height: 8, SideA: 6, SideB: 8, SideC: 10},
104        Circle{Radius: 3},
105        Rectangle{Width: 7, Height: 7},
106    }
107
108    printReport(shapes)
109
110    largest := largestShape(shapes)
111    fmt.Printf("\nLargest shape: %s (area: %.2f)\n", largest.Name(), largest.Area())
112}
Next article: In part 7 we will explore concurrency in Go with goroutines and channels. We will discover why Go is so popular for concurrent programs and how to write code that executes multiple tasks simultaneously in a safe way.
Share:
CV

Cristhian Villegas

Software Engineer specializing in Java, Spring Boot, Angular & AWS. Building scalable distributed systems with clean architecture.

Comments

Sign in to leave a comment

No comments yet. Be the first!

Related Articles