Struct #

Go doesn’t have class. Not because it was forgotten, but because of a very deliberate design decision. Classes in OOP bring inheritance with them — and inheritance brings complexity that often outweighs its benefits: the fragile base class problem, diamond inheritance, tight coupling between parent and child. Go chose a different path: composition. Instead of inheriting behavior from another class, you build new types by combining existing ones. struct is the foundation of all of this — and when combined with methods and interfaces, it can express every OOP pattern you need without the accompanying complexity.

Defining a Struct #

A struct is a collection of fields grouped into a single type. Define it with the type and struct keywords:

type Person struct {
    // Exported fields — capitalized, accessible from other packages
    Name    string
    Age     int
    Email   string

    // Unexported fields — lowercase, only within this package
    password string
    loginAt  time.Time
}

// Structs can be nested
type Address struct {
    Street   string
    City     string
    Province string
    ZipCode  string
}

type Employee struct {
    Name       string
    Department string
    Salary     float64
    Address    Address  // a struct as a field (not embedded — it has a name)
    JoinDate   time.Time
}

Separating exported and unexported fields isn’t just access control — it’s how you define a struct’s public API. Exported fields are the part you promise to the package’s users. Unexported fields are implementation details you’re free to change anytime.


Ways to Initialize a Struct #

There are several ways to create a struct instance, each with its own advantages.

p := Person{
    Name:  "Budi Santoso",
    Age:   28,
    Email: "[email protected]",
}

Use named fields almost always. The reason: if the struct gains a new field later, this code stays valid and the compiler won’t error.

Positional — Avoid for Structs with More Than 2 Fields #

// ANTI-PATTERN: fragile against struct changes
p := Person{"Budi", 28, "[email protected]", "", time.Time{}}

// If the field order changes or a new field is added in the middle,
// every positional initialization will assign the wrong values
// without any compile error!

Zero Value — All Fields Default #

var p Person
// p.Name    = ""
// p.Age     = 0
// p.Email   = ""
// p.password = ""
// p.loginAt  = time.Time{} (zero time)

The zero value is very useful when a struct is well designed — its zero value is already a valid state.

Address Literal — Pointer Directly #

// Produces a *Person, not a Person
p := &Person{
    Name:  "Budi",
    Age:   28,
    Email: "[email protected]",
}
// p is a *Person
fmt.Println(p.Name)  // Go auto-dereferences: no need for (*p).Name

new() — Pointer to the Zero Value #

p := new(Person)   // equivalent to &Person{}
p.Name = "Budi"    // fill fields one by one
p.Age  = 28

In practice, &Person{...} is more common than new(Person) because you can fill in values right away.


Structs Are Value Types #

This is an important difference from other OOP languages: structs in Go are value types. When you assign a struct to another variable or pass it to a function, Go makes a complete copy of all its fields:

type Point struct {
    X, Y int
}

p1 := Point{X: 1, Y: 2}
p2 := p1         // complete copy — p2 is a COPY of p1
p2.X = 99

fmt.Println(p1)  // {1 2} — unchanged!
fmt.Println(p2)  // {99 2}

Its implications for functions:

// ANTI-PATTERN: modifying a struct in a function doesn't affect the original
func setName(p Person, name string) {
    p.Name = name  // only modifies the local copy
}

func main() {
    p := Person{Name: "Budi"}
    setName(p, "Sari")
    fmt.Println(p.Name)  // "Budi" — unchanged!
}

// Solution 1: return a new struct (idiomatic for small changes)
func withName(p Person, name string) Person {
    p.Name = name  // modify the copy
    return p       // return the modified copy
}

// Solution 2: accept a pointer (idiomatic for large structs or many modifications)
func setNamePtr(p *Person, name string) {
    p.Name = name  // modify the original struct
}

Methods — Value Receiver vs Pointer Receiver #

Methods in Go are attached to a type through a receiver — an extra argument before the method name.

Value Receiver — Works on a Copy #

type Rectangle struct {
    Width, Height float64
}

// Value receiver (r Rectangle) — r is a copy
func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

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

func (r Rectangle) Scale(factor float64) Rectangle {
    // Return a new struct — doesn't modify the original
    return Rectangle{
        Width:  r.Width * factor,
        Height: r.Height * factor,
    }
}

Pointer Receiver — Works on the Original #

// Pointer receiver (*Rectangle) — modifies the original struct
func (r *Rectangle) ScaleInPlace(factor float64) {
    r.Width  *= factor
    r.Height *= factor
}

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

    // Value receiver — doesn't change rect
    bigger := rect.Scale(2)
    fmt.Println(rect)   // {10 5} — unchanged
    fmt.Println(bigger) // {20 10}

    // Pointer receiver — changes rect
    rect.ScaleInPlace(2)
    fmt.Println(rect)   // {20 10} — changed!
}

Guide to Choosing a Receiver #

USE A VALUE RECEIVER if:
  ✓ The method only reads data (getters, calculations)
  ✓ The struct is small and cheap to copy (Point, Color, Size)
  ✓ The type is designed to be immutable (like time.Time)
  ✓ The method returns a new value rather than modifying

USE A POINTER RECEIVER if:
  ✓ The method modifies the struct
  ✓ The struct is large (many fields, expensive to copy)
  ✓ The struct contains a sync.Mutex or fields that must not be copied
  ✓ Consistency — if one method uses a pointer, all use pointers

CONSISTENCY RULE (most important):
  If one method uses a pointer receiver, ALL methods
  on that type should use a pointer receiver.
  Don't mix them unless there's a very strong reason.

Don’t mix value receivers and pointer receivers in one type. This causes confusion about which methods are “safe” to call on a value vs a pointer, and can cause subtle bugs related to interface satisfaction.

// ANTI-PATTERN: mixed receivers
type Counter struct{ count int }
func (c Counter)  Value() int   { return c.count }    // value receiver
func (c *Counter) Increment()   { c.count++ }         // pointer receiver
func (c *Counter) Reset()       { c.count = 0 }       // pointer receiver

// CORRECT: consistent with pointer receivers
func (c *Counter) Value() int   { return c.count }
func (c *Counter) Increment()   { c.count++ }
func (c *Counter) Reset()       { c.count = 0 }

Embedding — Composition, Not Inheritance #

Embedding lets one struct “include” another — all fields and methods of the embedded struct can be accessed directly, as if they belonged to the wrapping struct. This is Go’s way of expressing “is-a” relationships without inheritance.

In classical object-oriented programming, relationships are depicted as rigid inheritance. In Go, this relationship is replaced with embedding (composition), where the wrapping structure includes the base structure as a whole, as shown in the following diagram:

flowchart TD
    subgraph Inheritance["Classic Inheritance (Other Languages)"]
        Parent["Parent Class (Animal)\n- Name\n- Age\n- Breathe()"]
        Child["Child Class (Dog)\n- Breed\n- Trained"]
        Child -.->|"extends (Inherits)"| Parent
    end

    subgraph Composition["Go Composition (Embedding)"]
        Dog["Struct Dog\n- Breed\n- Trained\n- (Embedded Animal)"]
        Animal["Struct Animal\n- Name\n- Age\n- Breathe()"]
        Dog -->|"includes (has-a)"| Animal
    end
type Animal struct {
    Name string
    Age  int
}

func (a *Animal) Breathe() {
    fmt.Printf("%s is breathing\n", a.Name)
}

func (a *Animal) Describe() string {
    return fmt.Sprintf("%s (age %d years)", a.Name, a.Age)
}

// Dog "embeds" Animal — not "extends" Animal
type Dog struct {
    Animal          // embedded without a field name
    Breed  string
    Trained bool
}

func (d *Dog) Bark() {
    fmt.Printf("%s is barking!\n", d.Name)  // access d.Animal.Name directly
}

func main() {
    d := Dog{
        Animal:  Animal{Name: "Buddy", Age: 3},
        Breed:   "Labrador",
        Trained: true,
    }

    // Access Animal fields directly (promoted fields)
    fmt.Println(d.Name)     // "Buddy" — equivalent to d.Animal.Name
    fmt.Println(d.Age)      // 3

    // Access Animal methods directly (promoted methods)
    d.Breathe()             // "Buddy is breathing"
    fmt.Println(d.Describe()) // "Buddy (age 3 years)"

    // Dog's own methods
    d.Bark()                // "Buddy is barking!"

    // Explicit access if needed
    fmt.Println(d.Animal.Name)  // same as d.Name
}

Overriding Methods from an Embedded Struct #

The embedding struct can define a method with the same name to “override” the embedded struct’s method:

type Base struct {
    ID int
}

func (b Base) Describe() string {
    return fmt.Sprintf("Base ID: %d", b.ID)
}

type Extended struct {
    Base
    Name string
}

// Override Describe — Extended has its own implementation
func (e Extended) Describe() string {
    return fmt.Sprintf("%s (ID: %d)", e.Name, e.ID)
}

func main() {
    e := Extended{Base: Base{ID: 42}, Name: "Server A"}
    fmt.Println(e.Describe())       // "Server A (ID: 42)" — the Extended version
    fmt.Println(e.Base.Describe())  // "Base ID: 42" — explicit access to the Base version
}

Embedding Multiple Structs #

type Logger struct{}
func (l Logger) Log(msg string) { fmt.Println("[LOG]", msg) }

type Metrics struct{}
func (m Metrics) Record(key string, val float64) {
    fmt.Printf("[METRIC] %s = %.2f\n", key, val)
}

// Service has both logging and metrics capabilities
type Service struct {
    Logger
    Metrics
    Name string
}

func (s *Service) Process(data string) {
    s.Log("processing: " + data)
    // do something...
    s.Record("processing_time", 0.025)
}

Struct Tags #

Struct tags are metadata added to fields — a string literal appearing after the field’s type. They’re most commonly used for JSON serialization, database mapping, and validation:

import (
    "encoding/json"
    "time"
)

type User struct {
    ID        int       `json:"id"                    db:"id"`
    Username  string    `json:"username"               db:"username"`
    Email     string    `json:"email"                  db:"email"`
    Password  string    `json:"-"                      db:"password_hash"`
    // json:"-" → ignore this field during JSON marshal/unmarshal
    CreatedAt time.Time `json:"created_at"             db:"created_at"`
    UpdatedAt time.Time `json:"updated_at,omitempty"   db:"updated_at"`
    // omitempty → ignore if the value is the zero value
    IsAdmin   bool      `json:"is_admin"               db:"is_admin"`
    Score     float64   `json:"score,omitempty"        db:"score"`
}

JSON Serialization with Tags #

func main() {
    user := User{
        ID:       1,
        Username: "budi99",
        Email:    "[email protected]",
        Password: "hashedpassword123",
        IsAdmin:  false,
    }

    // Struct → JSON
    data, err := json.Marshal(user)
    if err != nil {
        panic(err)
    }
    fmt.Println(string(data))
    // Output: {"id":1,"username":"budi99","email":"[email protected]",
    //          "created_at":"0001-01-01T00:00:00Z","is_admin":false}
    // Password doesn't appear (json:"-")
    // UpdatedAt doesn't appear (omitempty + zero value)
    // Score doesn't appear (omitempty + zero value 0.0)

    // JSON → Struct
    jsonStr := `{"id":2,"username":"sari","email":"[email protected]","is_admin":true}`
    var user2 User
    if err := json.Unmarshal([]byte(jsonStr), &user2); err != nil {
        panic(err)
    }
    fmt.Printf("User: %s, Admin: %v\n", user2.Username, user2.IsAdmin)
}

Tags for Validation #

// With the go-playground/validator library
type CreateUserRequest struct {
    Username string `json:"username" validate:"required,min=3,max=50,alphanum"`
    Email    string `json:"email"    validate:"required,email"`
    Password string `json:"password" validate:"required,min=8,max=128"`
    Age      int    `json:"age"      validate:"min=0,max=150"`
}

Anonymous Structs #

An anonymous struct is a struct without a named type — declared and used directly. Useful for temporary data that doesn’t need a reusable type:

// Inline config — no separate type definition needed
config := struct {
    Host     string
    Port     int
    Debug    bool
    Timeout  time.Duration
}{
    Host:    "localhost",
    Port:    5432,
    Debug:   true,
    Timeout: 30 * time.Second,
}

fmt.Printf("Connect to %s:%d\n", config.Host, config.Port)

// Table-driven tests — a very common Go pattern
tests := []struct {
    name     string
    input    string
    expected int
    wantErr  bool
}{
    {name: "valid number",   input: "42",  expected: 42,  wantErr: false},
    {name: "negative",       input: "-1",  expected: -1,  wantErr: false},
    {name: "invalid string", input: "abc", expected: 0,   wantErr: true},
    {name: "empty string",   input: "",    expected: 0,   wantErr: true},
}

for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        got, err := strconv.Atoi(tt.input)
        if (err != nil) != tt.wantErr {
            t.Errorf("wantErr %v, got err %v", tt.wantErr, err)
        }
        if got != tt.expected {
            t.Errorf("expected %d, got %d", tt.expected, got)
        }
    })
}

Struct Comparability #

Structs can be compared with == and != only if all their fields are comparable. Fields of slice, map, or function types make a struct non-comparable:

type Point struct {
    X, Y int
}

p1 := Point{1, 2}
p2 := Point{1, 2}
p3 := Point{3, 4}

fmt.Println(p1 == p2)  // true  — all fields equal
fmt.Println(p1 == p3)  // false — fields differ
fmt.Println(p1 != p3)  // true

// Structs with non-comparable fields can't be compared
type Container struct {
    Items []int  // slices are not comparable
}

c1 := Container{Items: []int{1, 2, 3}}
c2 := Container{Items: []int{1, 2, 3}}
// fmt.Println(c1 == c2)  // ← compile error: struct containing []int cannot be compared

// For non-comparable structs, use reflect.DeepEqual
import "reflect"
fmt.Println(reflect.DeepEqual(c1, c2))  // true

The Constructor Pattern #

Go doesn’t have a built-in constructor. A very common convention is creating a NewXxx() function that returns a properly validated and initialized instance (usually a pointer):

type Server struct {
    host    string
    port    int
    timeout time.Duration
    maxConn int
    logger  *Logger
}

// Simple constructor
func NewServer(host string, port int) *Server {
    return &Server{
        host:    host,
        port:    port,
        timeout: 30 * time.Second,  // sensible default
        maxConn: 100,
        logger:  defaultLogger,
    }
}

// Functional options pattern — for many optional options
type Option func(*Server)

func WithTimeout(d time.Duration) Option {
    return func(s *Server) { s.timeout = d }
}

func WithMaxConn(n int) Option {
    return func(s *Server) { s.maxConn = n }
}

func WithLogger(l *Logger) Option {
    return func(s *Server) { s.logger = l }
}

func NewServerWithOptions(host string, port int, opts ...Option) *Server {
    s := &Server{
        host:    host,
        port:    port,
        timeout: 30 * time.Second,
        maxConn: 100,
    }
    for _, opt := range opts {
        opt(s)
    }
    return s
}

// Usage — very expressive
server := NewServerWithOptions(
    "localhost", 8080,
    WithTimeout(60 * time.Second),
    WithMaxConn(500),
    WithLogger(customLogger),
)

Complete Example Program #

The following program builds a library management system using various struct concepts:

package main

import (
    "fmt"
    "strings"
    "time"
)

// ── Basic Types ─────────────────────────────────────────────

type BookID int
type MemberID int

type Author struct {
    Name        string
    Nationality string
}

func (a Author) String() string {
    return fmt.Sprintf("%s (%s)", a.Name, a.Nationality)
}

type Book struct {
    ID        BookID
    Title     string
    Author    Author
    ISBN      string
    Year      int
    Available bool
    Tags      []string
}

func NewBook(id BookID, title string, author Author, isbn string, year int) *Book {
    return &Book{
        ID:        id,
        Title:     title,
        Author:    author,
        ISBN:      isbn,
        Year:      year,
        Available: true,
    }
}

func (b *Book) Checkout() error {
    if !b.Available {
        return fmt.Errorf("book %q is currently borrowed", b.Title)
    }
    b.Available = false
    return nil
}

func (b *Book) Return() {
    b.Available = true
}

func (b Book) String() string {
    status := "available"
    if !b.Available {
        status = "borrowed"
    }
    return fmt.Sprintf("[%d] %q by %s (%d) — %s",
        b.ID, b.Title, b.Author.Name, b.Year, status)
}

// ── Member with Embedding ─────────────────────────────────

type Person struct {
    Name  string
    Email string
    Phone string
}

func (p Person) ContactInfo() string {
    return fmt.Sprintf("%s <%s>", p.Name, p.Email)
}

type Member struct {
    Person              // embedding — a Member "is" a Person
    ID          MemberID
    JoinDate    time.Time
    BorrowedBooks []*Book
}

func NewMember(id MemberID, name, email, phone string) *Member {
    return &Member{
        Person:   Person{Name: name, Email: email, Phone: phone},
        ID:       id,
        JoinDate: time.Now(),
    }
}

// Override ContactInfo with additional information
func (m Member) ContactInfo() string {
    return fmt.Sprintf("%s <%s> (ID: %d)", m.Name, m.Email, m.ID)
}

func (m *Member) Borrow(book *Book) error {
    if len(m.BorrowedBooks) >= 3 {
        return fmt.Errorf("%s already borrowed 3 books (maximum limit)", m.Name)
    }
    if err := book.Checkout(); err != nil {
        return err
    }
    m.BorrowedBooks = append(m.BorrowedBooks, book)
    fmt.Printf("✓ %s borrowed %q\n", m.Name, book.Title)
    return nil
}

func (m *Member) ReturnBook(bookID BookID) error {
    for i, b := range m.BorrowedBooks {
        if b.ID == bookID {
            b.Return()
            m.BorrowedBooks = append(m.BorrowedBooks[:i], m.BorrowedBooks[i+1:]...)
            fmt.Printf("✓ %s returned %q\n", m.Name, b.Title)
            return nil
        }
    }
    return fmt.Errorf("book ID %d not found in %s's borrowings", bookID, m.Name)
}

func (m Member) Status() string {
    if len(m.BorrowedBooks) == 0 {
        return fmt.Sprintf("%s — not borrowing any books", m.Name)
    }
    titles := make([]string, len(m.BorrowedBooks))
    for i, b := range m.BorrowedBooks {
        titles[i] = fmt.Sprintf("%q", b.Title)
    }
    return fmt.Sprintf("%s — borrowing: %s", m.Name, strings.Join(titles, ", "))
}

// ── Library ─────────────────────────────────────────────────

type Library struct {
    Name    string
    Books   map[BookID]*Book
    Members map[MemberID]*Member
}

func NewLibrary(name string) *Library {
    return &Library{
        Name:    name,
        Books:   make(map[BookID]*Book),
        Members: make(map[MemberID]*Member),
    }
}

func (l *Library) AddBook(book *Book) {
    l.Books[book.ID] = book
}

func (l *Library) RegisterMember(member *Member) {
    l.Members[member.ID] = member
}

func (l *Library) Report() {
    available := 0
    for _, b := range l.Books {
        if b.Available {
            available++
        }
    }

    fmt.Printf("\n=== %s Report ===\n", l.Name)
    fmt.Printf("Total books   : %d (%d available, %d borrowed)\n",
        len(l.Books), available, len(l.Books)-available)
    fmt.Printf("Total members : %d\n\n", len(l.Members))

    fmt.Println("Book Collection:")
    for _, b := range l.Books {
        fmt.Printf("  %s\n", b)
    }

    fmt.Println("\nMember Statuses:")
    for _, m := range l.Members {
        fmt.Printf("  %s\n", m.Status())
    }
}

func main() {
    lib := NewLibrary("Go Library")

    // Add books using the constructor
    books := []*Book{
        NewBook(1, "The Go Programming Language",
            Author{"Alan Donovan", "American"}, "978-0134190440", 2015),
        NewBook(2, "Go in Action",
            Author{"William Kennedy", "American"}, "978-1617291784", 2015),
        NewBook(3, "Concurrency in Go",
            Author{"Katherine Cox-Buday", "American"}, "978-1491941195", 2017),
        NewBook(4, "Clean Code",
            Author{"Robert Martin", "American"}, "978-0132350884", 2008),
    }
    for _, b := range books {
        lib.AddBook(b)
    }

    // Register members
    members := []*Member{
        NewMember(1, "Budi Santoso", "[email protected]", "081234567890"),
        NewMember(2, "Sari Dewi", "[email protected]", "082345678901"),
    }
    for _, m := range members {
        lib.RegisterMember(m)
    }

    // Simulate borrowing
    fmt.Println("=== Borrowing Activity ===")

    budi := members[0]
    sari := members[1]

    // Budi borrows two books
    if err := budi.Borrow(books[0]); err != nil {
        fmt.Println("Error:", err)
    }
    if err := budi.Borrow(books[2]); err != nil {
        fmt.Println("Error:", err)
    }

    // Sari tries to borrow a book Budi already borrowed
    if err := sari.Borrow(books[0]); err != nil {
        fmt.Println("✗ Error:", err)
    }

    // Sari borrows another book
    if err := sari.Borrow(books[1]); err != nil {
        fmt.Println("Error:", err)
    }

    // Budi returns one book
    if err := budi.ReturnBook(1); err != nil {
        fmt.Println("Error:", err)
    }

    // Now Sari can borrow the previously unavailable book
    if err := sari.Borrow(books[0]); err != nil {
        fmt.Println("Error:", err)
    }

    // ContactInfo uses the embedded Person.ContactInfo and the override
    fmt.Printf("\nBudi's Contact Info (Person): %s\n", budi.Person.ContactInfo())
    fmt.Printf("Budi's Contact Info (Member): %s\n", budi.ContactInfo())

    // Final report
    lib.Report()
}

Summary #

  • Go doesn’t have classes — struct + method + interface replace OOP classes in a more explicit and composable way.
  • Use named fields when initializing — safer and more resilient to struct changes.
  • Structs are value types — assignment and passing to functions create copies; use pointers for large structs or when you need to modify the original.
  • Value receivers for read-only methods; pointer receivers for methods that modify or for large structs. Pick one and stay consistent across the whole type.
  • Embedding is composition, not inheritance — promoted fields and methods make code more expressive without tight coupling.
  • Struct tags (json:"name", db:"column") are metadata for serialization and mapping — essential for APIs and databases.
  • json:"-" excludes a field from JSON; omitempty ignores zero values during marshaling.
  • Anonymous structs are useful for inline configuration and table-driven tests.
  • The NewXxx() constructor pattern ensures structs are always created in a valid state.
  • The functional options pattern for constructors with many optional options.

← Previous: Functions   Next: Interface →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact