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.
Named Fields — The Recommended Way #
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
endtype 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;omitemptyignores 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.