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)
}
She ran it:
{ 0}
"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)
}
Output:
{Alice 30}
Name: Alice
Age: 30
"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)
}
She ran it:
{The Go Programming Language Donovan and Kernighan 380 2015}
{Learning Go Jon Bodner 375 2021}
"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)
}
}
Output:
Library catalog:
1. 1984 by George Orwell (328 pages)
2. Dune by Frank Herbert (688 pages)
3. Foundation by Isaac Asimov (255 pages)
"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())
}
She ran it:
Rectangle: {5 3}
Area: 15
Perimeter: 16
"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)
}
Output:
Before: 0
After: 0
"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)
}
Output:
Before: 0
After: 1
"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
"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)
}
Output:
Alice
Boston
"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())
}
Output:
Tesla Model S with 670 HP Electric engine
"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
}
She ran it:
Seattle
Seattle
"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)