Structs and Composition
Learn how to create custom types with structs and build complex types through composition. Understand embedded fields, struct tags, and Go's approach to code reuse without inheritance.
Programs need to model complex data. A user has a name, email, and creation date. An HTTP request has a method, URL, headers, and body. A database connection has a host, port, username, and timeout settings. Basic types like integers and strings represent individual values, but real systems need to group related data together.
Go provides structs—composite types that group fields of different types under a single name. Structs are Go's primary mechanism for creating custom data types. Combined with methods, they provide the foundation for organizing code without classes or inheritance hierarchies.
Defining Structs
A struct is a collection of fields:
type Person struct {
Name string
Age int
Email string
}
This defines a new type called Person with three fields. Each field has a name and a type. Fields can be any type—basic types, other structs, slices, maps, functions.
Create instances using struct literals:
p := Person{
Name: "Alice",
Age: 30,
Email: "[email protected]",
}
fmt.Println(p.Name) // Alice
fmt.Println(p.Age) // 30
Field names must be specified in any order:
p := Person{
Email: "[email protected]",
Name: "Alice",
Age: 30,
}
You can omit fields—they get their zero values:
p := Person{
Name: "Bob",
Age: 25,
}
fmt.Println(p.Email) // "" (empty string)
Alternatively, provide values in the order fields are declared, without names:
p := Person{"Alice", 30, "[email protected]"}
This is concise but fragile—adding fields or reordering them breaks existing code. Named fields are more maintainable. Use positional initialization only for small, stable structs like Point{X: 10, Y: 20}.
Accessing Fields
Access struct fields with dot notation:
p := Person{Name: "Alice", Age: 30}
fmt.Println(p.Name) // Alice
p.Age = 31
fmt.Println(p.Age) // 31
Structs are values, not references. Assigning a struct copies all fields:
p1 := Person{Name: "Alice", Age: 30}
p2 := p1
p2.Name = "Bob"
fmt.Println(p1.Name) // Alice
fmt.Println(p2.Name) // Bob
Modifying p2 doesn't affect p1 because p2 is a complete copy. This is the same behavior as arrays—structs are values.
Zero Values
The zero value of a struct has all fields set to their zero values:
var p Person
fmt.Println(p.Name) // ""
fmt.Println(p.Age) // 0
fmt.Println(p.Email) // ""
This guarantee means every struct is valid—no uninitialized state. Design structs so their zero value is useful:
type Buffer struct {
data []byte
}
var b Buffer // data is nil, but b is usable
b.data = append(b.data, 'x')
The zero value of Buffer has a nil slice, but appending to a nil slice allocates automatically. The struct works without explicit initialization.
Nested Structs
Struct fields can be other structs:
type Address struct {
Street string
City string
ZipCode string
}
type Person struct {
Name string
Age int
Address Address
}
p := Person{
Name: "Alice",
Age: 30,
Address: Address{
Street: "123 Main St",
City: "Springfield",
ZipCode: "12345",
},
}
fmt.Println(p.Address.City) // Springfield
Access nested fields with chained dots: p.Address.City. Each dot accesses a field of the previous struct.
Anonymous Structs
You can declare structs without naming the type:
person := struct {
Name string
Age int
}{
Name: "Alice",
Age: 30,
}
fmt.Println(person.Name) // Alice
Anonymous structs are useful for one-off data structures:
response := struct {
Status int
Message string
}{
Status: 200,
Message: "OK",
}
json.Marshal(response)
Or for grouping related local variables:
config := struct {
timeout time.Duration
retries int
}{
timeout: 5 * time.Second,
retries: 3,
}
Anonymous structs keep temporary data structures localized. Use them when the type isn't reused elsewhere.
Struct Embedding
Go doesn't have inheritance. Instead, it has struct embedding—placing one struct inside another without giving it a field name:
type User struct {
ID int
Username string
}
type Admin struct {
User
Privileges []string
}
admin := Admin{
User: User{
ID: 1,
Username: "alice",
},
Privileges: []string{"read", "write", "delete"},
}
fmt.Println(admin.ID) // 1
fmt.Println(admin.Username) // alice
fmt.Println(admin.Privileges) // [read write delete]
The Admin struct embeds User. Fields of User are promoted to Admin—you can access admin.ID directly instead of admin.User.ID. The embedded struct is still accessible as admin.User if needed.
This is composition, not inheritance. An Admin is not a User in the polymorphic sense. There's no type hierarchy. The embedded struct's fields become available on the outer struct.
Multiple structs can be embedded:
type Timestamped struct {
CreatedAt time.Time
UpdatedAt time.Time
}
type Identifiable struct {
ID string
}
type Document struct {
Timestamped
Identifiable
Title string
Content string
}
doc := Document{
Timestamped: Timestamped{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Identifiable: Identifiable{
ID: "doc-123",
},
Title: "My Document",
Content: "...",
}
fmt.Println(doc.ID) // doc-123
fmt.Println(doc.CreatedAt) // 2024-10-26 ...
Both Timestamped and Identifiable fields promote to Document. This pattern builds functionality through composition—small, focused types combine into larger ones.
Field Promotion and Shadowing
Embedded fields promote to the outer struct, but outer fields take precedence:
type Inner struct {
X int
}
type Outer struct {
Inner
X int // shadows Inner.X
}
o := Outer{
Inner: Inner{X: 10},
X: 20,
}
fmt.Println(o.X) // 20 (Outer.X)
fmt.Println(o.Inner.X) // 10 (Inner.X)
The outer X field shadows the inner one. You can still access the inner field through the embedded type name.
If multiple embedded structs have the same field name, access requires disambiguation:
type A struct {
X int
}
type B struct {
X int
}
type C struct {
A
B
}
c := C{
A: A{X: 10},
B: B{X: 20},
}
fmt.Println(c.A.X) // 10
fmt.Println(c.B.X) // 20
// fmt.Println(c.X) // Compile error: ambiguous selector c.X
The promoted field is ambiguous. You must specify which embedded struct's field you want.
Comparing Structs
Structs are comparable if all their fields are comparable:
type Point struct {
X, Y int
}
p1 := Point{1, 2}
p2 := Point{1, 2}
p3 := Point{2, 3}
fmt.Println(p1 == p2) // true
fmt.Println(p1 == p3) // false
Comparison checks if all corresponding fields are equal.
Structs containing slices, maps, or functions are not comparable:
type Data struct {
Values []int
}
d1 := Data{Values: []int{1, 2, 3}}
d2 := Data{Values: []int{1, 2, 3}}
fmt.Println(d1 == d2) // Compile error: struct containing []int cannot be compared
You can compare such structs to nil in specific contexts (like checking if a pointer is nil), but not to each other.
Struct Tags
Struct fields can have tags—string metadata attached to fields:
type Person struct {
Name string `json:"name"`
Age int `json:"age"`
Email string `json:"email,omitempty"`
}
Tags are string literals in backticks. They're accessible through reflection and used by packages like encoding/json:
p := Person{Name: "Alice", Age: 30, Email: "[email protected]"}
data, _ := json.Marshal(p)
fmt.Println(string(data)) // {"name":"Alice","age":30,"email":"[email protected]"}
The json package reads the tags to map struct fields to JSON keys. The omitempty option excludes fields with zero values from the output.
Tags don't affect the struct's behavior in your code—they're metadata for other packages. We'll see more tag usage when exploring the standard library.
Struct Memory Layout
Structs lay out fields sequentially in memory, but the compiler may add padding for alignment:
type Example struct {
A bool // 1 byte
B int64 // 8 bytes
C bool // 1 byte
}
On most architectures, int64 must be aligned on 8-byte boundaries. The compiler inserts padding:
A: 1 byte
Padding: 7 bytes
B: 8 bytes
C: 1 byte
Padding: 7 bytes
Total: 24 bytes
Reordering fields can reduce padding:
type Example struct {
B int64 // 8 bytes
A bool // 1 byte
C bool // 1 byte
}
Now the layout is:
B: 8 bytes
A: 1 byte
C: 1 byte
Padding: 6 bytes
Total: 16 bytes
You saved 8 bytes per struct by grouping small fields together. For most programs, this doesn't matter. For data structures in hot paths or with millions of instances, alignment matters.
Use unsafe.Sizeof() to check struct size:
import "unsafe"
fmt.Println(unsafe.Sizeof(Example{})) // 16
Empty Structs
The empty struct struct{} contains no fields and uses zero bytes:
var x struct{}
fmt.Println(unsafe.Sizeof(x)) // 0
Empty structs are useful as sentinel values or set elements:
// Set implemented with map
type Set map[string]struct{}
s := make(Set)
s["apple"] = struct{}{}
s["banana"] = struct{}{}
if _, ok := s["apple"]; ok {
fmt.Println("apple is in the set")
}
Using struct{} as the value type signals that values don't matter—only keys matter. And it uses no memory for values.
Methods on Structs
We'll explore methods deeply in the next article, but here's a preview. You can attach functions to structs:
type Rectangle struct {
Width float64
Height float64
}
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
rect := Rectangle{Width: 10, Height: 5}
fmt.Println(rect.Area()) // 50
The method Area is called on a Rectangle value. The syntax (r Rectangle) before the function name is the receiver—it specifies what type this method belongs to.
Methods make structs feel like objects in other languages, but they're simpler. Methods are functions with a receiver parameter. There's no implicit this or self—the receiver is explicit in the signature.
When to Use Structs
Use structs to group related data that represents a single concept:
type User struct {
ID int
Username string
Email string
CreatedAt time.Time
}
Don't use structs just to avoid multiple function parameters. If data doesn't form a cohesive concept, separate parameters are clearer:
// Bad: struct just to group unrelated parameters
type DrawParams struct {
X, Y int
Color string
Opacity float64
}
func Draw(params DrawParams) { ... }
// Good: parameters are distinct concerns
func Draw(x, y int, color string, opacity float64) { ... }
Structs shine when data has identity—it represents something in your domain. Users, orders, connections, configurations—these are entities with multiple attributes.
Composition Over Inheritance
Go's embedding is composition, not inheritance. There's no virtual dispatch, no overriding, no polymorphism through type hierarchy. You build complex types by composing simpler ones:
type Reader interface {
Read([]byte) (int, error)
}
type Writer interface {
Write([]byte) (int, error)
}
type ReadWriter struct {
Reader
Writer
}
A ReadWriter combines reading and writing capabilities by embedding two interfaces. It doesn't inherit—it delegates. Calls to Read go to the embedded Reader, calls to Write go to the embedded Writer.
This approach scales better than deep inheritance hierarchies. You compose exactly the functionality you need without dragging along a base class's baggage.
Struct Literals in Function Calls
Pass struct literals directly to functions:
func CreateUser(u User) error {
// ...
}
err := CreateUser(User{
Username: "alice",
Email: "[email protected]",
})
This is clearer than building the struct separately when you don't need to reference it again.
For large structs or when setting many fields, separate construction improves readability:
user := User{
ID: 123,
Username: "alice",
Email: "[email protected]",
FirstName: "Alice",
LastName: "Smith",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Active: true,
Role: "admin",
}
err := CreateUser(user)
Struct Patterns
Builder pattern for complex construction:
type Config struct {
Host string
Port int
Timeout time.Duration
Retries int
}
func NewConfig() *Config {
return &Config{
Host: "localhost",
Port: 8080,
Timeout: 5 * time.Second,
Retries: 3,
}
}
func (c *Config) WithHost(host string) *Config {
c.Host = host
return c
}
func (c *Config) WithPort(port int) *Config {
c.Port = port
return c
}
config := NewConfig().
WithHost("api.example.com").
WithPort(443)
This pattern is common for optional configuration. The default struct provides sensible defaults, and builder methods customize as needed.
Options struct for optional parameters:
type QueryOptions struct {
Limit int
Offset int
SortBy string
}
func Query(sql string, opts QueryOptions) []Row {
// Use opts.Limit, opts.Offset, opts.SortBy
}
rows := Query("SELECT * FROM users", QueryOptions{
Limit: 10,
SortBy: "created_at",
})
This avoids functions with many optional parameters while keeping defaults simple (zero values work).
What's Next
Structs group data. Methods attach behavior to structs. The combination provides Go's approach to structuring programs without classes.
The next article explores methods deeply—value receivers vs pointer receivers, when to use each, and how methods interact with interfaces. Then we'll tackle pointers explicitly, understanding when to pass values and when to pass addresses.
Structs are Go's primary abstraction for data. Combined with interfaces (which we'll cover after methods), they enable flexible, maintainable code without inheritance hierarchies. The simplicity is powerful—every struct you see is just fields, and every method is just a function with a receiver.
Ready to dive into methods and how they attach behavior to types?
