DEV Community

Cover image for The Secret Life of Go: Structs
Aaron Rose
Aaron Rose

Posted on

The Secret Life of Go: Structs

Chapter 6: Building Your Own Types

Monday morning arrived with the scent of rain on concrete. Ethan descended the familiar stairs, coffee tray in one hand, a white bakery bag in the other.

Eleanor looked up from her laptop. "What's the occasion?"

"Croissants. The baker said they're architectural—all those layers held together by structure."

She smiled. "Perfect. Today we're building structures of our own. Sit."

Ethan set down the coffees and took his seat. "Structures?"

"Structs. Go's way of creating custom types that group related data together. Until now, we've used Go's built-in types—int, string, bool, slices, maps. Today, we make our own."

Eleanor opened a new file:

package main

import "fmt"

type Person struct {
    Name string
    Age  int
}

func main() {
    var p Person
    fmt.Println(p)
}
Enter fullscreen mode Exit fullscreen mode

She ran it:

{ 0}
Enter fullscreen mode Exit fullscreen mode

"This is a struct. type Person struct defines a new type called Person. Inside the curly braces, we list fields—pieces of data that belong to a Person. Each Person has a Name (string) and an Age (int)."

"And when we print it?"

"We get the zero value. An empty string for Name, zero for Age. Structs follow Go's zero value philosophy—every type has a sensible default."

Eleanor modified the code:

package main

import "fmt"

type Person struct {
    Name string
    Age  int
}

func main() {
    p := Person{
        Name: "Alice",
        Age:  30,
    }

    fmt.Println(p)
    fmt.Println("Name:", p.Name)
    fmt.Println("Age:", p.Age)
}
Enter fullscreen mode Exit fullscreen mode

Output:

{Alice 30}
Name: Alice
Age: 30
Enter fullscreen mode Exit fullscreen mode

"Now we're initializing the struct with values. Person{Name: "Alice", Age: 30} creates a Person with those specific fields. We access fields with dot notation: p.Name, p.Age."

Ethan typed along. "So a struct is like... a container for related data?"

"Exactly. Think of it as a blueprint. The type Person struct definition says 'here's what a Person looks like.' Each instance of Person is a specific person with their own name and age."

Eleanor pulled out her checking paper. "Let's build something more realistic—a book catalog:"

package main

import "fmt"

type Book struct {
    Title  string
    Author string
    Pages  int
    Year   int
}

func main() {
    book1 := Book{
        Title:  "The Go Programming Language",
        Author: "Donovan and Kernighan",
        Pages:  380,
        Year:   2015,
    }

    book2 := Book{
        Title:  "Learning Go",
        Author: "Jon Bodner",
        Pages:  375,
        Year:   2021,
    }

    fmt.Println(book1)
    fmt.Println(book2)
}
Enter fullscreen mode Exit fullscreen mode

She ran it:

{The Go Programming Language Donovan and Kernighan 380 2015}
{Learning Go Jon Bodner 375 2021}
Enter fullscreen mode Exit fullscreen mode

"Each Book has four fields. We create two different books, each with their own data. The struct groups everything that describes a book."

"Could I make a slice of books?"

Eleanor's eyes gleamed. "Absolutely. Watch:"

package main

import "fmt"

type Book struct {
    Title  string
    Author string
    Pages  int
}

func main() {
    library := []Book{
        {Title: "1984", Author: "George Orwell", Pages: 328},
        {Title: "Dune", Author: "Frank Herbert", Pages: 688},
        {Title: "Foundation", Author: "Isaac Asimov", Pages: 255},
    }

    fmt.Println("Library catalog:")
    for i, book := range library {
        fmt.Printf("%d. %s by %s (%d pages)\n", 
            i+1, book.Title, book.Author, book.Pages)
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

Library catalog:
1. 1984 by George Orwell (328 pages)
2. Dune by Frank Herbert (688 pages)
3. Foundation by Isaac Asimov (255 pages)
Enter fullscreen mode Exit fullscreen mode

"A slice of structs. Each element in library is a Book. We iterate with range and access each book's fields. This is how you model collections of complex data in Go."

Ethan watched the output. "In Python, I'd use a class. This feels simpler."

"It is. Go doesn't have classes. Structs are just data—they hold values. But watch what happens when we add behavior:"

package main

import "fmt"

type Rectangle struct {
    Width  float64
    Height float64
}

func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

func (r Rectangle) Perimeter() float64 {
    return 2 * (r.Width + r.Height)
}

func main() {
    rect := Rectangle{Width: 5, Height: 3}

    fmt.Println("Rectangle:", rect)
    fmt.Println("Area:", rect.Area())
    fmt.Println("Perimeter:", rect.Perimeter())
}
Enter fullscreen mode Exit fullscreen mode

She ran it:

Rectangle: {5 3}
Area: 15
Perimeter: 16
Enter fullscreen mode Exit fullscreen mode

"These are methods. func (r Rectangle) Area() defines a function that belongs to Rectangle. The (r Rectangle) part—called a receiver—says 'this function operates on a Rectangle.'"

"So methods are just functions with receivers?"

"Exactly. When you call rect.Area(), Go passes rect as the receiver r, and the method can access its fields. It's elegant—methods are functions, receivers are parameters, but the syntax makes it feel natural."

Eleanor opened a new file. "Now, here's something important. Watch what happens when we try to modify a struct in a method:"

package main

import "fmt"

type Counter struct {
    Value int
}

func (c Counter) Increment() {
    c.Value++
}

func main() {
    counter := Counter{Value: 0}
    fmt.Println("Before:", counter.Value)

    counter.Increment()
    fmt.Println("After:", counter.Value)
}
Enter fullscreen mode Exit fullscreen mode

Output:

Before: 0
After: 0
Enter fullscreen mode Exit fullscreen mode

"It didn't change. The counter is still zero."

Ethan frowned. "Why?"

"Because receivers work like function parameters—they're passed by value. When Increment() runs, it gets a copy of counter. It modifies the copy, not the original."

"How do we fix it?"

Eleanor typed:

package main

import "fmt"

type Counter struct {
    Value int
}

func (c *Counter) Increment() {
    c.Value++
}

func main() {
    counter := Counter{Value: 0}
    fmt.Println("Before:", counter.Value)

    counter.Increment()
    fmt.Println("After:", counter.Value)
}
Enter fullscreen mode Exit fullscreen mode

Output:

Before: 0
After: 1
Enter fullscreen mode Exit fullscreen mode

"Now it works. The receiver is (c *Counter)—a pointer to a Counter. The method receives the address of the original struct, so changes persist."

She drew in her notebook:

Value Receiver:     func (r Rectangle) Area()
- Gets a copy
- Can't modify original
- Use for read-only methods

Pointer Receiver:   func (c *Counter) Increment()
- Gets the address
- Can modify original  
- Use for methods that change state
Enter fullscreen mode Exit fullscreen mode

"This is a fundamental choice in Go. Value receivers for methods that just read data. Pointer receivers for methods that modify data."

Ethan thought about this. "So if I have a method that needs to change the struct, I always use a pointer receiver?"

"Usually, yes. But there's more to it. Even for read-only methods, you might use pointer receivers for large structs—copying big structures is expensive. Pointers are cheap."

Eleanor opened a new example:

package main

import "fmt"

type Address struct {
    Street string
    City   string
    State  string
    Zip    string
}

type Person struct {
    Name    string
    Age     int
    Address Address
}

func main() {
    person := Person{
        Name: "Alice",
        Age:  30,
        Address: Address{
            Street: "123 Main St",
            City:   "Boston",
            State:  "MA",
            Zip:    "02101",
        },
    }

    fmt.Println(person.Name)
    fmt.Println(person.Address.City)
}
Enter fullscreen mode Exit fullscreen mode

Output:

Alice
Boston
Enter fullscreen mode Exit fullscreen mode

"Structs can contain other structs. Person has an Address field, which is itself a struct. We access nested fields with chaining: person.Address.City."

"This is composition?"

"Yes. Go doesn't have inheritance—you can't say 'Person extends Human' like in Java. Instead, you compose types from smaller pieces. A Person has an Address. Simple and explicit."

Eleanor typed another example:

package main

import "fmt"

type Engine struct {
    Horsepower int
    Type       string
}

type Car struct {
    Make   string
    Model  string
    Engine Engine
}

func (c Car) Describe() string {
    return fmt.Sprintf("%s %s with %d HP %s engine",
        c.Make, c.Model, c.Engine.Horsepower, c.Engine.Type)
}

func main() {
    car := Car{
        Make:  "Tesla",
        Model: "Model S",
        Engine: Engine{
            Horsepower: 670,
            Type:       "Electric",
        },
    }

    fmt.Println(car.Describe())
}
Enter fullscreen mode Exit fullscreen mode

Output:

Tesla Model S with 670 HP Electric engine
Enter fullscreen mode Exit fullscreen mode

"A Car has an Engine. The Describe() method accesses both Car fields and nested Engine fields. Everything is explicit—you see exactly what data exists and how it's organized."

Eleanor paused. "Now, there's a shortcut Go provides. Watch this:"

package main

import "fmt"

type Address struct {
    City  string
    State string
}

type Person struct {
    Name    string
    Address // Embedded field - no field name, just the type
}

func main() {
    person := Person{
        Name: "Bob",
        Address: Address{
            City:  "Seattle",
            State: "WA",
        },
    }

    // Both of these work:
    fmt.Println(person.Address.City) // Explicit access
    fmt.Println(person.City)          // Promoted field access
}
Enter fullscreen mode Exit fullscreen mode

She ran it:

Seattle
Seattle
Enter fullscreen mode Exit fullscreen mode

"When you embed a struct without giving it a field name—just Address instead of Address Address—the inner struct's fields are promoted to the outer struct. You can still access person.Address.City explicitly, but you can also use the shorthand person.City."

Ethan studied the code. "So it's like the Person absorbs the Address fields?"

"Not quite. The Address still exists as a distinct field. But Go lets you access its fields as if they belonged to Person directly. It's syntactic sugar for composition—a convenience that makes deeply nested structs easier to work with."

"When would I use this?"

"When you're building up types from smaller pieces and want cleaner access syntax. You'll see this pattern everywhere in Go code—especially when dealing with interfaces, which we'll cover next time."

Ethan was quiet for a moment. "This feels different from object-oriented programming."

"It is. In classical OOP, you have classes with private fields and public methods, inheritance hierarchies, polymorphism through interfaces. Go strips it down. Structs hold data. Methods operate on structs. Composition replaces inheritance. It's simpler, but it takes adjustment."

Eleanor closed her laptop. "That's the foundation of structs. Custom types that group related data. Methods with receivers that operate on that data. Value receivers for reading, pointer receivers for modifying. Composition—whether explicit or embedded—for building complex types from simple ones."

She took a bite of croissant. "Next time: interfaces. How Go achieves polymorphism without inheritance."

Ethan gathered the cups. "Eleanor?"

"Yes?"

"You said Go doesn't have classes. But these structs with methods... they feel like classes."

Eleanor smiled. "They do. But there's a crucial difference. In Java or C++, a class owns its methods—they're part of the class definition. In Go, methods are separate. You can add methods to any type defined in your package. Structs and methods are loosely coupled—you don't have to define all methods at once like you do with classes."

"Why does that matter?"

"Flexibility. In Go, you define the data, then add behavior where you need it. In classical OOP, you define both at once, in a rigid hierarchy. Go's approach is more composable, more flexible. It takes getting used to, but it scales better for large systems."

Ethan climbed the stairs, thinking about structures and receivers and the way Go separated data from behavior. In Python, everything was an object. In Go, data was data, and functions operated on data, but you could make it feel like methods when you wanted.

Maybe that was the pattern: Go gave you the pieces—structs for data, functions for behavior, receivers to connect them—and let you assemble them however you needed. No inheritance to constrain you. No classes to lock you in. Just composition, all the way down.


Key Concepts from Chapter 6

Structs: Custom types that group related fields together. Defined with type Name struct { fields }.

Fields: Named values within a struct. Each field has a name and a type.

Struct literals: Create instances with TypeName{Field1: value1, Field2: value2}.

Field access: Use dot notation to access fields: instance.FieldName.

Zero values: Uninitialized structs have zero values for all fields (0, "", false, nil, etc.).

Methods: Functions with receivers. Syntax: func (receiver Type) MethodName() returnType.

Receivers: The (r Type) part that connects a method to a type. Can be value or pointer receivers.

Value receivers: func (r Rectangle) Area() - receives a copy of the struct. Use for read-only operations.

Pointer receivers: func (c *Counter) Increment() - receives the address of the struct. Use when modifying the struct or for large structs.

Nested structs: Structs can contain other structs as fields. Access with chaining: outer.Inner.Field.

Embedded structs: Structs can embed other structs without a field name (e.g., just Address instead of Address Address). The embedded struct's fields are promoted to the outer struct, allowing shorthand access.

Composition: Building complex types from simpler ones. Go's alternative to inheritance.

No classes: Go doesn't have classes. Structs hold data, methods provide behavior, but they're separate concepts.

Slices of structs: You can create []StructType to hold collections of structured data.

Method definition scope: You can add methods to any type defined in your package. Methods and types are loosely coupled.

Struct tags: Optional metadata attached to fields (e.g., `json:"name"`) used by packages like encoding/json for serialization. You'll encounter these when working with JSON, databases, and other data formats.


Next chapter: Interfaces—where Ethan learns about Go's approach to polymorphism, and Eleanor explains why implicit interface satisfaction is so powerful.


Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.

Top comments (0)