Structs, Methods and Interfaces in Go: Complete Guide

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
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}
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:
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}
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.
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}
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
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}
*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.
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}
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.
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}
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
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
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).
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}
Practical example: geometric shape calculator
Let us build a complete program that combines structs, methods and interfaces to create a geometric shape calculator.
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}
Comments
Sign in to leave a comment
No comments yet. Be the first!