Go Basics #6 Structs and Methods

7 min read

In #5 Collections you saw the tools for grouping data. This time — we’re making types that carry their own meaning: structs and methods.

Struct — user-defined types #

basic struct
type User struct {
	ID   string
	Name string
	Age  int
}

func main() {
	u := User{
		ID:   "u1",
		Name: "Curtis",
		Age:  30,
	}
	fmt.Println(u)         // {u1 Curtis 30}
	fmt.Println(u.Name)    // Curtis
}

Define with type Name struct { fields }. Each field gets its type.

Several ways to create #

struct instances
// named fields (recommended)
u1 := User{ID: "u1", Name: "Curtis", Age: 30}

// some fields only (rest are zero values)
u2 := User{Name: "Alice"}

// positional (not recommended — breaks if you reorder fields)
u3 := User{"u3", "Bob", 35}

// zero value
var u4 User    // {  0}

// pointer via new
u5 := new(User)   // *User, all fields zero

The named-field form is the standard. Positional is fragile when fields are added or reordered.

Field access — dot notation #

field access
u := User{Name: "Curtis", Age: 30}
fmt.Println(u.Name)
u.Age = 31

You also use dot notation through pointers — Go automatically dereferences.

access through a pointer
p := &u
fmt.Println(p.Name)   // OK — it's (*p).Name but auto-dereferenced
p.Age = 32             // OK

Methods — functions bound to a type #

basic method
type User struct {
	Name string
	Age  int
}

func (u User) Greet() {
	fmt.Printf("hi, %s (%d)\n", u.Name, u.Age)
}

func main() {
	u := User{Name: "Curtis", Age: 30}
	u.Greet()    // hi, Curtis (30)
}

The (u User) part of func (u User) Greet() is the receiver. This function is a method bound to the User type.

The receiver name is usually the lowercase first letter of the type (u for User). Go convention is to spell out the receiver each time, not use this/self.

Value receiver vs pointer receiver #

This is the most confusing part at first.

Value receiver — receives a copy #

value receiver
func (u User) IncrementAge() {
	u.Age++             // increments the copy's Age — original unchanged
}

u := User{Age: 30}
u.IncrementAge()
fmt.Println(u.Age)   // 30 (no change)

A value receiver copies the whole struct on call. Changes inside the method don’t affect the original.

Pointer receiver — points at the original #

pointer receiver
func (u *User) IncrementAge() {
	u.Age++             // increments the original's Age
}

u := User{Age: 30}
u.IncrementAge()
fmt.Println(u.Age)   // 31

A *User receiver takes a pointer — changes inside the method are reflected on the original.

Which to use? #

Guidelines:

  1. If you need to mutate → pointer receiver
  2. If the struct is large → pointer receiver (avoid the copy cost)
  3. Otherwise → value receiver

In practice the most-used guideline is — stay consistent within a type. Mixing pointer receivers and value receivers on the same type feels off, so typically you unify all methods on a type to one kind.

Auto-deref / auto-pointer in Go #

The caller barely thinks about either of these.

automatic conversion
u := User{Age: 30}
p := &u

u.PointerMethod()     // automatically (&u).PointerMethod()
p.ValueMethod()       // automatically (*p).ValueMethod()

Whether the caller has a value or a pointer, the Go compiler does the conversion. Just note that to mutate, the caller must hold an addressable value (a transient location like a slice element may not be addressable).

Constructors — the New... convention #

Go has no class or explicit constructor. Instead, constructor functions are made by convention.

New-function pattern
type User struct {
	ID   string
	Name string
	Age  int
}

func NewUser(name string, age int) *User {
	return &User{
		ID:   generateID(),
		Name: name,
		Age:  age,
	}
}

func main() {
	u := NewUser("Curtis", 30)
	fmt.Println(u.Name)
}

NewUser, NewClientNew<TypeName> is the standard name. Usually returns a pointer.

Embedding — reuse via composition #

Go has no class inheritance, but you can get a similar effect with embedding.

embedding
type Animal struct {
	Name string
}

func (a Animal) Speak() {
	fmt.Println(a.Name, "makes a sound")
}

type Dog struct {
	Animal           // embedded — type only, no field name
	Breed string
}

func main() {
	d := Dog{
		Animal: Animal{Name: "Bori"},
		Breed:  "Shiba",
	}
	d.Speak()        // can call Animal's method directly
	fmt.Println(d.Name)   // direct access to Name on Dog
}

The Animal inside Dog is embedded — written as the type only, with no field name. This means:

  • You can call Animal’s method (Speak) directly on Dog
  • You can access Animal’s field (Name) directly as d.Name

It’s not inheritance but promotion — from the outside it looks like Dog directly has those methods/fields.

Method override #

If you define a method with the same name on Dog, Dog’s method hides the promoted one.

override
func (d Dog) Speak() {
	fmt.Println(d.Name, "barks (woof woof)")
}

d := Dog{Animal: Animal{Name: "Bori"}, Breed: "Shiba"}
d.Speak()    // Bori barks (woof woof)

// To call Animal's method explicitly
d.Animal.Speak()

Similar to JS’s super.methodd.Animal.Speak() lets you call the (sort of) parent’s method directly.

Embedding vs inheritance — the difference #

Different from traditional OOP inheritance in these ways:

  • Dog has an Animal, not is an Animal
  • Multiple embedding is allowed (type X struct { A; B })
  • It’s not an “is-a” relation but “has-a” + method promotion

One of Go’s design philosophies — composition over inheritance. Deep class hierarchies are intentionally blocked.

Struct comparison #

Two structs of the same type are == true when every field matches.

struct comparison
u1 := User{Name: "Curtis", Age: 30}
u2 := User{Name: "Curtis", Age: 30}
u3 := User{Name: "Alice", Age: 25}

u1 == u2   // true
u1 == u3   // false

But — a struct that has slice/map/function fields is not comparable (compile error). For those cases, use reflect.DeepEqual or your own Equal method.

Anonymous struct #

A struct made on the spot without a name.

anonymous struct
config := struct {
	Host string
	Port int
}{
	Host: "localhost",
	Port: 8080,
}

fmt.Println(config.Host, config.Port)

Suitable for test cases or one-off data groupings. For data you’ll reuse, give it a name with type.

Tags — metadata #

You can attach tags (metadata) to struct fields. They are central to JSON serialization and DB mapping.

JSON tags
type User struct {
	ID   string `json:"id"`
	Name string `json:"name"`
	Age  int    `json:"age,omitempty"`
}

The encoding/json package reads these tags — mapping Go field names (ID) to JSON keys (id). omitempty says to skip the field in the result when it’s the zero value.

Detailed in Intermediate #7 Standard Library Tour and Practice #3 JSON.

Common patterns #

1) Builder pattern — method chaining #

builder pattern
type Query struct {
	table  string
	limit  int
	where  []string
}

func NewQuery() *Query {
	return &Query{}
}

func (q *Query) Table(t string) *Query {
	q.table = t
	return q
}

func (q *Query) Where(w string) *Query {
	q.where = append(q.where, w)
	return q
}

func (q *Query) Limit(n int) *Query {
	q.limit = n
	return q
}

// usage
q := NewQuery().
	Table("users").
	Where("active = true").
	Limit(10)

Each method returns itself, enabling chaining. A common shape in library APIs.

2) Options pattern — functional options #

When a constructor needs many options.

functional options
type Server struct {
	host    string
	port    int
	timeout time.Duration
}

type Option func(*Server)

func WithHost(host string) Option {
	return func(s *Server) { s.host = host }
}

func WithPort(port int) Option {
	return func(s *Server) { s.port = port }
}

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

func NewServer(opts ...Option) *Server {
	s := &Server{
		host:    "localhost",
		port:    8080,
		timeout: 30 * time.Second,
	}
	for _, opt := range opts {
		opt(s)
	}
	return s
}

// usage
srv := NewServer(
	WithPort(3000),
	WithTimeout(60 * time.Second),
)

Option functions gather configuration. Nearly standard in Go libraries.

Wrap-up #

What we covered:

  • type X struct { ... } for user-defined types
  • Instances — named fields are recommended
  • Methods — func (r Type) Method() form
  • Value vs pointer receivers — pointer when you need to mutate or the struct is large
  • Keep methods on a type uniform in receiver kind
  • New... convention for constructor-like functions
  • Embedding for composition + method promotion — not inheritance
  • Same-type structs support == (when fields are comparable)
  • Tags for metadata — used in JSON/DB mapping
  • Builder pattern, functional options pattern

In the next post (#7 Packages and Modules) — the final basics post — we cover splitting code across files/packages and managing external dependencies with go mod.

X