Skip to content
beginner Phase 4 · Structs, Methods, and Interfaces

Defining Structs and Literals

Group fields into structs, build literals, and control field visibility.

40m
0 problems
Topic Progress 0%

From Four Loose Variables to One Value

The handler starts simple and degrades fast: userID, userEmail, userVerified, userBalance — four parameters that always arrive together, get passed together, and occasionally arrive as three because someone forgot one. The data describes a single thing, so the type system should say so.

A struct groups named fields, each with its own type:

type Account struct {
	ID       int64
	Email    string
	Balance  float64
	Verified bool
}

Now Account is one value with one meaning. It can be a parameter, a return value, a map value, a slice element — and adding a field later changes the type, not every signature that mentions it.

Every struct has a zero value, and it exists even if you never assign anything:

var a Account
// a == Account{ID: 0, Email: "", Balance: 0, Verified: false}

b := Account{ID: 7, Email: "sam@example.com"}
// b.Balance == 0, b.Verified == false

The zero value follows the field types: numbers go to 0, strings to "", bools to false, pointers/slices/maps/channels to nil. Go's design leans on this — a declared-but-uninitialised sync.Mutex is unlocked and ready, a bytes.Buffer is usable, an Account from var is a valid (empty) account. Fields you leave out of a literal fall back to zero, so a partial literal always compiles to a predictable value.

Construction uses a composite literal:

acct := Account{
	ID:       42,
	Email:    "sam@example.com",
	Balance:  19.99,
	Verified: true,
}

Two syntax details matter before you read more Go: field order in a literal is always Name: value, the trailing comma after the last field is required when the literal spans lines, and the type name is repeated at each nesting level for nested structs (Point{X: 1, Y: 2} inside Shape{Origin: Point{...}}). Structs are values — copy := acct duplicates every field — which chapter 3 explores, because that rule has one famous exception hiding in it.

Keyed Literals Survive the Next Refactor

Go permits positional literals — values in declaration order, no field names:

acct := Account{42, "sam@example.com", 19.99, true}

It is shorter today and a bug tomorrow. The day someone inserts Plan string after Email, every positional literal shifts: a string lands in the Balance slot, the compiler catches the type mismatch if you are lucky, and a string into string field added at the end means old literals silently assign Verified = false. Code review has to reconstruct declaration order to verify each literal — the opposite of what a literal is for.

Always use keyed literals in application code:

acct := Account{ID: 42, Email: "sam@example.com"}

Adding a field never touches this line; omitted fields take their zero values; every line documents which value is which. The compiler enforces unknown names, so a typo is a build failure. go vet will also warn about unkeyed literals of types imported from other packages, which is the same lesson applied across package boundaries.

The names on a struct do double duty once serialization enters the picture. Field names are what encoding/json uses by default ("ID" becomes the key ID), and struct tags let you override that per format:

type Account struct {
	ID      int64   `json:"id"`
	Email   string  `json:"email"`
	Balance float64 `json:"balance"`
	SSN     string  `json:"-"` // never leaves the package
}

The tag is a string literal attached to the field, read by reflection — json is just the most common consumer (db, validate, and yaml tags follow the same shape). A field name is an internal choice; the tag is the external contract. This is the boundary where "rename freely inside, keep the wire format stable" lives, and it is the first place to look when a JSON key changes unexpectedly after a refactor (JSON Handling).

The Copy That Wasn't Quite a Copy

Assigning a struct copies it — that is the rule, and for plain fields it behaves exactly as expected:

orig := Account{Email: "a@example.com"}
copy := orig
copy.Email = "b@example.com"
// orig.Email is still "a@example.com" — Email was copied

Now add a slice:

type Account struct {
	Email string
	Tags  []string
}

orig := Account{Email: "a@example.com", Tags: []string{"beta"}}
copy := orig
copy.Tags[0] = "gamma"
copy.Email = "c@example.com"

fmt.Println(orig.Tags[0]) // gamma — NOT beta
fmt.Println(orig.Email)   // a@example.com

What happened? The struct copy duplicated every field — and the slice field is a header: pointer, length, capacity. Both headers now point at the same backing array, so copy.Tags[0] = ... writes through to storage orig still references. Strings, numbers, and bools are self-contained values; slices, maps, and channels are references to shared state. The copy is complete; the thing being pointed at was never part of it.

Three consequences follow, and they explain most "struct aliasing" bugs:

  • Mutating a slice/map field of one copy is visible in every copy made from it.
  • To truly separate them, copy the underlying data: Tags: append([]string(nil), orig.Tags...).
  • To make the sharing intentional, use a pointer field: Tags *[]string — or better, give the group its own named type and methods (Methods with Receivers).

Value semantics also govern equality. A struct is comparable with == only when every field is: Point{1, 2} == Point{1, 2} is true, but a struct containing a slice, map, or function cannot use == at all — the compiler rejects it, because the runtime has no meaningful answer for "are these two references the same collection?" The workaround for identity is what you would expect: compare a key field, or compare pointers with == when you mean "the same object."

The engineering judgment: structs are values by default, which makes them safe to pass around and easy to reason about — until a reference field smuggles shared state in. When a struct's fields are all self-contained values, copies are cheap and boring. When it holds references, decide deliberately whether copies should share, and make that visible in the design.

A Capital Letter Is a Public API

Go's visibility rule has no keywords: an identifier is exported (visible outside its package) if and only if its first letter is uppercase. That single rule turns struct fields into an API surface.

// in package accounts

type Account struct {
	ID      int64  // exported: any package may read and write it
	email   string // unexported: only package accounts can touch it
}

A caller in another package can write acct.ID but cannot even name email — not in a literal, not in a selector, not in a struct tag trick. Encapsulation in Go is package-level, and the type declaration is where you draw the line between what callers may depend on and what your package keeps free to change.

This creates a design fork for any type with unexported fields. From outside the package, accounts.Account{ID: 1} compiles and leaves email at its zero value — which may be exactly wrong for an invariant like "an account always has an email." The package must offer a constructor:

package accounts

func NewAccount(id int64, email string) (Account, error) {
	if !strings.Contains(email, "@") {
		return Account{}, fmt.Errorf("invalid email %q", email)
	}
	return Account{ID: id, email: email}, nil
}

Now validation lives in one place, the unexported field can never be set to garbage from outside, and the signature tells callers what the type actually requires. When every field is exported and any combination is valid — a request payload, a config struct, a Point — no constructor is needed; the literal is the constructor.

Two tradeoffs to weigh honestly. Exported fields are convenient (callers can build, copy, and compare freely) but commit you to their names forever — renaming Balance breaks every dependent package, while renaming balance breaks nothing outside your own. And exported fields of a struct from another package can be set positionally-free but never read-restricted: if a value must not be mutated, hand out a copy or an interface, not a pointer. The guidance in the Go standard library applies to your code too: keep fields unexported unless a caller has a legitimate reason to touch them, and expose behavior rather than representation — which is the subject of the next topic.

Quiz

1. What does this print? ```go orig := Account{Email: "a@x.com", Tags: []string{"beta"}} copy := orig copy.Tags[0] = "gamma" copy.Email = "b@x.com" fmt.Println(orig.Tags[0], orig.Email) ```

Question 1 options

2. A module runs `var mu sync.Mutex` with no initialisation and immediately calls mu.Lock(). What happens?

Question 2 options

3. You add a field to Account and find a positional literal `Account{42, "sam", 10.0, true}` still compiles but assigns wrong values. What is the lesson?

Question 3 options

4. Why is this rejected by the compiler? `ok := pointA == pointB` where Point has a `Coords []float64` field.

Question 4 options

Flashcards

Question

What is the zero value of a struct you declare with `var`, and when is it wrong to use it?

Answer

Every field at its type's zero value: 0, "", false, nil for slices/maps/pointers. It is perfectly usable when zeros are valid (config defaults, a fresh sync.Mutex) and wrong when the type carries an invariant — an account with an empty email — which is when the package should expose a validating constructor.

Question

Why do Go programmers always use keyed struct literals?

Answer

Keys bind values to field names, so adding or reordering fields cannot silently shift arguments into the wrong slots, and an unknown field name is a compile error. Positional literals are shorter but couple every call site to the declaration order.

Question

You assign one struct to another and mutate a slice field on the copy — what does the original see?

Answer

The mutation, because copying a struct copies the slice header (pointer, len, cap) and both headers reference the same backing array. Strings and numbers are copied by value; slices, maps, and channels always smuggle shared storage along.

Question

When can a struct value be compared with ==?

Answer

Only when every field is comparable — numbers, strings, bools, arrays, and other comparable structs. A struct containing a slice, map, or function fails to compile with ==; compare a key field or the pointers instead.

Question

How does a struct field become private to its package, and what API shape does that force?

Answer

Lowercase the first letter — visibility in Go is purely capitalization, with no keyword. Once a field is unexported, outside packages cannot set it in a literal, so the type needs an exported constructor (NewAccount) that validates inputs and constructs the value on the package's behalf.

Revision Notes

Key Takeaways

  • 1. A struct is named fields with declared types; its zero value is every field cleared, and Go expects zeros to be usable
  • 2. Keyed literals are the default in application code — positional literals silently misbind when fields are inserted
  • 3. Assignment copies the struct, but slice/map fields point at shared storage — copying is not isolation
  • 4. Visibility is capitalization: lowercase fields define the package boundary and force a constructor when invariants exist
  • 5. Comparability, JSON keys, and mutation rules all follow from the field list — the struct declaration is the contract

Interview Tips

  • • "What's the zero value of a struct?" — every field at its zero; follow with when that is a feature (fresh mutex, default config) versus when it forces a constructor (invariant like non-empty email)
  • • "Value or pointer when passing structs?" — value copies, which is cheap for small all-value structs and wrong for slice/map fields you intend to share or mutate
  • • "Why keyed literals?" — a field insertion cannot reassign old call sites, and unknown keys fail the build; be ready to give the shifted-positional-argument example
  • • "How do you hide a field in Go?" — lowercase it; then explain that the package must offer New() if outside construction would violate an invariant

Cheat Sheet

Defining Structs and Literals

Declaration and construction:

Form Rule
var a Account zero value: every field cleared to its type's zero
Account{ID: 1} keyed literal — omitted fields take zero values
Account{1, "x"} positional — fragile, avoid in application code
ID int64 `json:"id"` tag overrides serialization name; json:"-" hides the field
lowercase field unexported: only the defining package can read or write it

Copy model: assignment duplicates the struct field-by-field; slice/map/channel fields are headers, so the copy shares the underlying storage. Deep-copy with append([]T(nil), s...) when separation is intended.

Comparability: struct == works iff all fields are comparable (no slices/maps/funcs).

Code template:

type Account struct {
	ID      int64   `json:"id"`
	Email   string  `json:"email"`
	Balance float64 `json:"balance"`
	tags    []string
}

func NewAccount(id int64, email string) (Account, error) {
	if !strings.Contains(email, "@") {
		return Account{}, fmt.Errorf("invalid email %q", email)
	}
	return Account{ID: id, Email: email}, nil
}

Continue Learning

Related Reading