Structs and Composition: Building Up, Not Down

Structs are Go's way of grouping data, and embedding is how it builds bigger types from smaller ones — promotion, comparability, the always-usable zero value, and why Go composes instead of inheriting. Compiled and run against Go 1.26.5.

A struct is a typed collection of fields grouped under one name. That is the whole idea, and it is deliberately humble. Go has no classes, so the struct is where your data lives, and everything else you might expect from a class — methods, “inheritance”, polymorphism — is layered on separately and, as we will see, differently. This chapter is about the data half: how you define a struct, the literal forms, the zero value that lets you skip constructors, when two structs are equal, and the mechanism Go uses instead of inheritance.

Defining and building a struct

You declare a struct type with type and a list of fields, each a name and a type:

type Book struct {
	Title  string
	Author string
	Pages  int
}

There are two ways to write a literal value of that type, and the difference is worth a habit. A named-field literal lists field: value pairs; order does not matter and any field you leave out gets its zero value. A positional literal lists just the values, in declaration order, and must include every field.

package main

import "fmt"

type Book struct {
	Title  string
	Author string
	Pages  int
}

func main() {
	a := Book{Title: "The Go Programming Language", Author: "Donovan & Kernighan", Pages: 380}
	b := Book{"The Go Programming Language", "Donovan & Kernighan", 380}
	var z Book

	fmt.Printf("a: %+v\n", a)
	fmt.Printf("b: %+v\n", b)
	fmt.Printf("z: %+v\n", z)
	fmt.Printf("z.Pages defaulted to %d\n", z.Pages)
}
$ go run .
a: {Title:The Go Programming Language Author:Donovan & Kernighan Pages:380}
b: {Title:The Go Programming Language Author:Donovan & Kernighan Pages:380}
z: {Title: Author: Pages:0}
z.Pages defaulted to 0

The %+v verb prints field names alongside values, which is the format you want when debugging structs. Prefer the named form in almost all real code. Positional literals break silently the day someone adds a field or reorders two, whereas named literals keep compiling and simply zero the new field. The positional form earns its keep only for tiny, stable types like a two-field point.

The zero-value struct is a real value

Notice var z Book above. We never assigned it anything, yet it is a complete, usable Book whose fields hold their zero values: empty strings and 0. This is not a null or an “uninitialized” object waiting to blow up. It is a genuine value, and building types so their zero value is immediately useful is one of the most Go idioms there is.

You saw this already with strings.Builder, whose zero value is a ready-to-use empty builder, and with bytes.Buffer and sync.Mutex, which work the same way. The payoff is that a great deal of Go code needs no constructor at all: you declare a variable and start using it. When a type does need setup, the convention is a function named NewBook returning a Book or *Book, but you reach for that only when the zero value would genuinely be invalid. Design your structs so that, wherever you can manage it, the zero value means something sensible.

Comparability: when == works

Structs are comparable with == as long as all of their fields are comparable. Two struct values are equal when every corresponding field is equal. This is a real, field-by-field comparison, not an identity check, and it is decided at compile time based on the field types.

package main

import "fmt"

type Point struct {
	X, Y int
}

func main() {
	p := Point{1, 2}
	q := Point{1, 2}
	r := Point{1, 3}

	fmt.Printf("p == q: %t\n", p == q)
	fmt.Printf("p == r: %t\n", p == r)

	seen := map[Point]bool{}
	seen[p] = true
	fmt.Printf("seen[q]: %t (q equals p)\n", seen[q])
}
$ go run .
p == q: true
p == r: false
seen[q]: true (q equals p)

p and q are separate values that compare equal because their fields match, and that equality is what lets a struct serve as a map keyseen[q] finds the entry stored under p. The caveat is the “all fields comparable” rule. Slices, maps, and functions are not comparable, so a struct containing any of them is not comparable either, and == on it will not compile. When you need to compare structs that hold such fields, reach for reflect.DeepEqual or write the comparison yourself.

Embedding: composition instead of inheritance

Here is where Go diverges sharply from class-based languages. To build a bigger type out of a smaller one, you embed it: you name a type inside a struct with no field name. The embedded type’s fields and methods are then promoted — you can reach them directly on the outer value, as if they were declared there.

package main

import "fmt"

type Engine struct {
	Horsepower int
}

func (e Engine) Start() string {
	return fmt.Sprintf("engine started (%d hp)", e.Horsepower)
}

type Car struct {
	Engine
	Brand string
}

func main() {
	c := Car{
		Engine: Engine{Horsepower: 250},
		Brand:  "Fiat",
	}

	fmt.Printf("c.Horsepower: %d\n", c.Horsepower)
	fmt.Printf("c.Start():    %s\n", c.Start())
	fmt.Printf("c.Engine.Horsepower: %d\n", c.Engine.Horsepower)
}
$ go run .
c.Horsepower: 250
c.Start():    engine started (250 hp)
c.Engine.Horsepower: 250

Car embeds Engine by writing just the type name. From then on c.Horsepower reaches the embedded field directly (promotion), and c.Start() calls the embedded method directly, even though Start is defined on Engine. The embedded value is still there under its type name too, so c.Engine.Horsepower reaches the same field the long way. Promotion is pure convenience; nothing is copied or hidden.

Now the crucial distinction: this is composition, not inheritance. A Car is not a subtype of Engine. There is no “is-a” relationship, no overriding in the object-oriented sense, and — the part that surprises people from a class background — you cannot pass a Car where an Engine is expected. Embedding gives Car the use of Engine’s fields and methods; it does not make Car an Engine. If a promoted method’s name clashes with one the outer type defines, the outer one wins and the inner one is simply not promoted, with no special “override” machinery involved.

This is the concrete meaning of the advice that Go favors composition over inheritance. You assemble behavior by embedding the pieces you want rather than deriving from a base class, and because there is no subtyping, you never inherit a tangle you did not ask for. Polymorphism, when you need it, comes from interfaces, which describe what a type can do rather than what it is — a separate mechanism we give its own chapter. Keep the two ideas apart: embedding shares implementation, interfaces express capability.

Anonymous structs and a first look at tags

Sometimes you want a struct value without bothering to name the type — a one-off shape for a local grouping or a test case. Go lets you write the struct type inline, an anonymous struct:

package main

import "fmt"

type User struct {
	Name  string `json:"name"`
	Email string `json:"email"`
}

func main() {
	pos := struct {
		Lat, Lng float64
	}{Lat: 51.5, Lng: -0.12}

	fmt.Printf("pos: %+v\n", pos)

	u := User{Name: "Ada", Email: "ada@example.com"}
	fmt.Printf("user: %+v\n", u)
}
$ go run .
pos: {Lat:51.5 Lng:-0.12}
user: {Name:Ada Email:ada@example.com}

The pos variable has a struct type that exists only at that spot. It is handy for throwaway groupings, and you will see it most in table-driven tests where each row is an anonymous struct.

The User type shows the other new thing: the backtick strings after each field, called struct tags. A tag is metadata attached to a field, ignored by the compiler but readable at runtime through reflection. The json:"name" tag tells the encoding/json package to use name as the key when it marshals or unmarshals that field. Other libraries define their own tag conventions for database columns, form fields, validation rules, and more. We give tags and JSON their full treatment in a later series, Building Real Things in Go; for now just recognize the syntax and know that it is how a struct field carries instructions to the libraries that process it.

What to carry forward

A struct groups fields under a type. Prefer named-field literals so your code survives field changes; lean on the zero value so you rarely need a constructor; and remember that == works structurally as long as every field is comparable, which is what lets structs be map keys. Embedding builds bigger types from smaller ones by promoting fields and methods, and it is composition — a Car uses an Engine, it is not one. That single choice, composition over inheritance, shapes how you model everything in Go.

Next: methods — how you attach behavior to your types, and the one receiver decision you make on every method.

Comments