🚧 We are still building the lighthouse. Hang tight!

projectlighthouse logoprojectlighthouse

Sign in with your Google or GitHub account to get started

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?

Notes & Highlights

© 2025 projectlighthouse. All rights reserved.