05 - Structs, Methods, and Interfaces¶
What this session is¶
A long one - about ninety minutes. You'll learn the three constructs that make Go feel like Go: structs (group related data), methods (functions attached to a type), and interfaces (a contract that says "if your type has these methods, you can use it anywhere this kind of thing is expected"). These three together are what people mean when they say Go is "lightweight object-oriented" - there are no classes, no inheritance, but you can still model real things and write code that's flexible and testable.
This chapter is denser than the others. If it feels like a lot, take the main flow first; the Going deeper section at the end is for after you've used the material once or twice.
The problem this solves¶
So far, every piece of data you've worked with has been a single value - one int, one string, one bool. Real things have many properties at once. A person has a name, an age, a city. A point on a graph has an x and a y. A rectangle has a width and a height.
You could pass each property as a separate parameter:
func describe(name string, age int, city string) {
fmt.Printf("%s, age %d, lives in %s\n", name, age, city)
}
That works for two or three. At six, you're sad. At twelve, you're lost. A struct lets you bundle them up.
A struct¶
package main
import "fmt"
type Person struct {
Name string
Age int
City string
}
func main() {
alice := Person{Name: "Alice", Age: 30, City: "Lagos"}
fmt.Println(alice)
fmt.Println(alice.Name)
fmt.Println(alice.Age)
}
Type it. Run it. You'll see:
What's new:
type Person struct { ... }- defines a new type calledPerson. It's a struct (a bundle of fields). The fields are listed inside the braces, one per line, each with a name and a type.Person{Name: "Alice", Age: 30, City: "Lagos"}- creates a value of typePerson, with the listed fields set. This is called a struct literal.alice.Name- dot notation to read a field. Works the same way to write:alice.Name = "Alicia"changes the name.
Naming convention: types are CapitalCase. Field names are also CapitalCase if you want them visible outside the package (more on that in page 11). For now, capitalize everything.
Structs as function parameters and returns¶
A struct value is just like any other value - you can pass it to functions and return it from them:
package main
import "fmt"
type Point struct {
X, Y int
}
func origin() Point {
return Point{X: 0, Y: 0}
}
func describe(p Point) {
fmt.Printf("at (%d, %d)\n", p.X, p.Y)
}
func main() {
o := origin()
describe(o)
describe(Point{X: 3, Y: 4})
}
Notice X, Y int - when adjacent fields share a type, you can group them on one line. Same idea as parameters.
Methods: functions attached to a type¶
A method is a function that belongs to a type. The only difference from a regular function is the receiver - an extra parameter declared before the function name.
package main
import (
"fmt"
"math"
)
type Point struct {
X, Y float64
}
func (p Point) DistanceFromOrigin() float64 {
return math.Sqrt(p.X*p.X + p.Y*p.Y)
}
func main() {
p := Point{X: 3, Y: 4}
fmt.Println(p.DistanceFromOrigin()) // 5
}
The line:
reads as: "define a method called DistanceFromOrigin. It's attached to the Point type. Inside the method, the value the method was called on is named p."
So p.DistanceFromOrigin() calls the method with p as the receiver.
New thing in this example: float64 is a type for numbers with decimal places. math.Sqrt takes a float64. We changed Point.X and Point.Y from int to float64 so it works.
You could have written this as a regular function:
That's not wrong. But methods read better at the call site:
The method form puts the most important thing (the point) first. As programs get bigger, this matters.
Value vs pointer receivers (the mental model)¶
There's a second form of receiver, with a * in front of the type:
This is a pointer receiver. The previous one (func (p Point) ...) is a value receiver.
The simple rule: a value receiver gets a copy; a pointer receiver gets the original.
Why this matters - concrete demo:
type Counter struct{ Value int }
func (c Counter) IncByValue() { c.Value++ } // operates on a copy
func (c *Counter) IncByPointer() { c.Value++ } // operates on the original
func main() {
c := Counter{}
c.IncByValue()
c.IncByValue()
fmt.Println(c.Value) // 0 - both increments mutated a copy and threw it away
c.IncByPointer()
c.IncByPointer()
fmt.Println(c.Value) // 2 - both increments wrote through to c
}
You'll meet pointers in full on page 08. For now, the practical rules of thumb:
- If the method changes a field of the receiver → use
*Type. Otherwise the change won't stick. - If the receiver is a big struct (lots of fields, or fields that are themselves big) → use
*Typeeven if you don't modify it. A pointer is one machine word; copying the whole struct would be wasted work. - If the receiver is small and immutable → either works. Pick one and use it for all methods on that type - consistency matters more than the choice itself.
When in doubt: use *Type. Real Go code uses pointer receivers around 80% of the time.
A bit of magic that makes this nicer: when you call c.IncByPointer() and c is a regular value (not a pointer), Go silently rewrites the call to (&c).IncByPointer() for you. The & is inserted automatically if c is addressable - that is, if it has a name (a variable, a struct field of a variable, an array element of a variable). This is why both c.IncByValue() and c.IncByPointer() look the same at the call site, even though the second technically requires a pointer. There's a corner case where this doesn't work - when the value is non-addressable, like a struct returned by a function - but you'll meet that rarely.
Try it: 1. Type the Counter example. Confirm the output is 0 then 2.
-
Try
Counter{Value: 5}.IncByPointer()directly. Compile error: "cannot take the address ofCounter{Value: 5}" - the struct literal has no name, so it's non-addressable. You can't take its address, so the auto-&insertion can't happen. Assign it to a variable first. -
Change
IncByValueto alsoreturn c(so it returns the copy after incrementing). Callc = c.IncByValue(). Now the change sticks - because you're explicitly assigning the returned copy back. This is howappendworks, by the way.
Methods on non-struct types¶
Methods aren't only for structs. You can define a method on any named type declared in the same package - including a named alias of int, string, float64, even a function type.
package main
import "fmt"
type ByteSize float64
const (
_ = iota
KB ByteSize = 1 << (10 * iota)
MB
GB
)
func (b ByteSize) String() string {
switch {
case b >= GB:
return fmt.Sprintf("%.2f GB", b/GB)
case b >= MB:
return fmt.Sprintf("%.2f MB", b/MB)
case b >= KB:
return fmt.Sprintf("%.2f KB", b/KB)
}
return fmt.Sprintf("%d B", int(b))
}
func main() {
var size ByteSize = 5_000_000
fmt.Println(size) // "4.77 MB"
}
That's a lot of new things in one example. Let's walk through it line by line so none of it is magic.
type ByteSize float64 - defines a brand-new type that has the same underlying representation as float64 but is a distinct type from the compiler's point of view. You can't add a ByteSize to a float64 without an explicit conversion - the compiler treats them as separate, even though they're the same shape in memory. This is exactly what lets us attach a method to it: methods can only be defined on named types declared in the current package, and float64 doesn't qualify (it's a built-in).
What is iota? Inside a const ( ... ) block, iota is a special built-in identifier that starts at 0 on the first line and increments by 1 on every subsequent line. That's the whole rule. So:
It's just a line-numbered counter, scoped to one const block. The first time iota shows up in a fresh const block, it's 0; the next line, 1; and so on. Go doesn't have an enum keyword - iota is how you build one.
Why is the first line _ = iota? The _ (blank identifier) means "throw this value away." We're consuming the line iota = 0 deliberately, because we don't want KB to be on the first line - if KB were on the first line, its calculation would use iota = 0, and we'll see in a second why that would give us the wrong number.
What does 1 << (10 * iota) actually compute? << is the bit-shift left operator. 1 << n means "take the number 1 and shift its bit n places to the left," which (for non-negative n) is exactly the same as 2^n. So:
1 << 10is2^10which is1024.1 << 20is2^20which is1,048,576(about 1 million).1 << 30is2^30which is1,073,741,824(about 1 billion).
Those three numbers are exactly 1 KB, 1 MB, and 1 GB in binary units (the "real" units a computer uses, as opposed to the decimal 1,000 / 1,000,000 / 1,000,000,000 that hard-drive marketing uses). Now apply the iota counter:
- Line
_ = iota->iota = 0. Value discarded. (If we had writtenKB = 1 << (10 * 0)here we'd getKB = 1, notKB = 1024- that's the line we wanted to skip.) - Line
KB ByteSize = 1 << (10 * iota)->iota = 1, soKB = 1 << 10 = 1024. - Line
MB->iota = 2, soMB = 1 << 20 = 1,048,576. - Line
GB->iota = 3, soGB = 1 << 30 = 1,073,741,824.
Wait - MB and GB don't have an = sign. How does Go know what they equal? A second Go rule for const blocks: if a line omits its right-hand side, Go silently repeats the previous line's expression (verbatim - including the iota reference, which has advanced). So MB re-uses 1 << (10 * iota) with iota now equal to 2, and GB re-uses it again with iota = 3. This "repeat the previous expression" rule is why iota-based enums in Go are so compact: you write the formula once on the first real line, and every subsequent line silently re-applies it with a fresh iota.
Three rules, working together: (1) iota counts lines in a const block; (2) 1 << n is 2^n; (3) an omitted RHS repeats the previous expression. That's the entire trick - no magic.
The String() string method. A method named exactly String with signature () string is special: it makes the type satisfy a built-in interface called fmt.Stringer. We haven't talked about interfaces yet (next section!), but here's the punchline now: whenever you pass a value to fmt.Println, fmt.Printf("%v", ...), or any of the fmt family, the package checks at runtime "does this value have a String() method?" If yes, it calls that method to get the printable form. If no, it falls back to the default formatting.
That's why fmt.Println(size) prints "4.77 MB" and not the raw float 4.76837158203125e+06: size is a ByteSize, ByteSize has a String() method, so Println calls it and prints whatever it returns.
And the "4.77 MB" itself? size is 5,000,000. The switch in String() checks: is 5,000,000 >= GB (about a billion)? No. Is it >= MB (about a million)? Yes. So we return fmt.Sprintf("%.2f MB", b/MB) -> 5,000,000 / 1,048,576 = 4.768... -> formatted as "4.77 MB". The division is real number division because ByteSize is float64 underneath, which is why we get .77 and not just an integer.
You can even define methods on a function type - Go's standard library uses this pattern in http.HandlerFunc:
type HandlerFunc func(http.ResponseWriter, *http.Request)
func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f(w, r)
}
A HandlerFunc is a function, but it also has a method (ServeHTTP). Why? So a plain function can be passed wherever an http.Handler interface is expected. We'll see the trick in detail in Going deeper.
The only restriction: the receiver's base type must be defined in the same package as the method. You can't add methods to int (a built-in) or to types from other packages (time.Time, bytes.Buffer, etc.). Define your own named type if you want to extend something.
Try it:
-
Run the
ByteSizeexample. Try printingByteSize(500),ByteSize(2_000_000_000). The output adapts to scale. -
Try
var size ByteSize = 1.5; var f float64 = size. Compile error: cannot usesize(typeByteSize) as typefloat64. You need an explicit conversion:f := float64(size). The distinct-type rule has teeth. -
Define
type WordCount intwith a methodPlural() stringthat returns"words"if the count != 1 and"word"otherwise. Use it:fmt.Println(WordCount(3).Plural()).
Interfaces: the contract¶
Here's the question interfaces solve: how do you write a function that works with anything that can do a certain thing, without knowing what that thing is?
Example. You want a function that writes some text "somewhere." Maybe a file. Maybe the terminal. Maybe a network connection. Maybe a memory buffer (for testing). They're all completely different concrete types, but they all support the operation "write some bytes." You want one function that takes any of them.
That's an interface. An interface is a contract that says "anything that has these methods can be used here."
package main
import "fmt"
// Any type with a Bark() method satisfies this interface.
type Barker interface {
Bark() string
}
type Dog struct{ Name string }
func (d Dog) Bark() string { return d.Name + " says woof!" }
type Fox struct{ Name string }
func (f Fox) Bark() string { return f.Name + " says ???" }
// makeNoise accepts ANY type that satisfies Barker.
func makeNoise(b Barker) {
fmt.Println(b.Bark())
}
func main() {
makeNoise(Dog{Name: "Rex"}) // Rex says woof!
makeNoise(Fox{Name: "Renard"}) // Renard says ???
}
Barker is a contract: "you can use anything as long as it has a Bark() string method." Dog has one. Fox has one. Both can be passed to makeNoise. makeNoise doesn't know or care about the concrete type - it just calls .Bark().
That's it. That's the whole idea. An interface is a behavior specification, and any type with the right methods automatically satisfies it.
Two pieces of syntax:
type Barker interface { Bark() string }- declares the interface type, listing the methods it requires.func makeNoise(b Barker)- usesBarkerlike any other type. Inside the function,bis an interface value; only the methods listed in the interface are available on it.
The one-method -er naming convention is universal in Go: io.Reader, io.Writer, fmt.Stringer, sort.Interface, error. When an interface has one method, name the interface after the method plus -er.
Try it: 1. Type the example. Confirm it prints both lines.
-
Add a
type Cat struct{ Name string }without aBark()method. TrymakeNoise(Cat{Name: "Whiskers"}). Compile error: "Cat does not implement Barker (missing Bark method)". The compiler checks at compile time, before your program runs. -
Add a
func (c Cat) Bark() string { return c.Name + " meows" }. NowmakeNoise(Cat{...})works. The cat satisfiesBarkerpurely because it has the right method - even though we never wrote anywhere thatCat"is a"Barker.
Implicit satisfaction: the Go way¶
The previous example showed something quietly profound: Cat satisfied Barker automatically. We never wrote class Cat implements Barker (Java), nor class Cat : public Barker (C++). The Cat type and the Barker interface might even be in different packages, written by different people, who never heard of each other.
This is called implicit interface satisfaction, and it's the central Go design choice for code reuse. The rule:
A type satisfies an interface as soon as it has all the methods the interface requires. No declaration is needed.
Three big consequences:
1. You can define an interface for a type you didn't write. Maybe you import a package with a type pdf.Document that has a Title() string method. You can define your own type Titled interface { Title() string } and use it to accept pdf.Document (and anything else with a Title() string). You don't need access to the original code.
2. You can add a new method to your own type and it automatically starts satisfying every interface that wants that method. The connections are made by the compiler at the points where you assign a concrete type to an interface variable.
3. Small interfaces become normal. Because you don't have to design hierarchies up front, real Go code is dotted with tiny one- or two-method interfaces, each precise about what it requires. io.Reader has one method. error has one method. fmt.Stringer has one method. The standard library's most flexible types satisfy dozens of different one-method interfaces.
Contrast with how you'd write this in Java:
// Java: every type that wants to be Barker must explicitly declare it.
public interface Barker {
String bark();
}
public class Dog implements Barker { // explicit "implements"
public String bark() { return "woof"; }
}
In Java, Dog must know about Barker at the moment it's defined. In Go, the relationship goes the other way: Barker (or any future interface) can discover Dog later, just by needing a method Dog happens to have. The decoupling is the win.
Try it: 1. Look at the standard library's fmt.Stringer:
String() string method satisfies it. You already defined one - ByteSize has String(). So ByteSize satisfies fmt.Stringer automatically - even though ByteSize lives in your package and Stringer lives in fmt. -
That's how
fmt.Println(size)printed"4.77 MB"earlier. Thefmtpackage internally doesif s, ok := v.(Stringer); ok { return s.String() }. YourByteSize.String()got called becauseByteSizehappens to satisfyStringer. -
The
errortype you've been using is also an interface:type error interface { Error() string }. Same mechanism. Any type withError() stringis an error.
A practical example: fmt.Stringer¶
fmt.Stringer is the most common interface a beginner runs into. It's used by the fmt package to ask types "how do you want to be printed?" Implement it once, and fmt.Println, fmt.Printf with %v, fmt.Sprintf, and string interpolation all use your version.
package main
import "fmt"
type Money struct {
Amount int64 // in cents
Currency string
}
// By giving Money a String() method, we satisfy fmt.Stringer.
// fmt.Println(m) will now call this method.
func (m Money) String() string {
return fmt.Sprintf("%s %.2f", m.Currency, float64(m.Amount)/100)
}
func main() {
price := Money{Amount: 1999, Currency: "USD"}
fmt.Println(price) // USD 19.99
fmt.Printf("paid %v\n", price) // paid USD 19.99
}
We never imported fmt.Stringer. We never wrote Money implements Stringer. We just defined a method with the right name and signature, and the fmt package's runtime check found us. This is implicit satisfaction at work.
Try it: 1. Run the example. Confirm both prints show USD 19.99.
-
Comment out the
String()method. Re-run. Nowfmt.Println(price)prints{1999 USD}- the default struct formatting. Thefmtpackage looked forStringer, didn't find it, fell back. -
Define
type Username stringwith aString()method that returns"@" + string(u). Print one. Notice: aUsernameis astringunderneath butStringercontrols how it prints.
The empty interface and any¶
There's a special interface that requires no methods:
Since every type has zero or more methods, every type satisfies interface{}. It's the type "anything at all." You'll see it used when a function needs to accept truly any value:
Println takes any number of arguments of any type - int, string, your Money struct, all mixed together. It works internally by inspecting the dynamic type of each argument at runtime (see Going deeper for how).
any is what Go uses where other languages might say Object or void *. Two things to know:
-
Functions that take
anylose all type information at the function boundary. Inside, you have to use type assertions or type switches (next section) to get back to a concrete type before you can do anything useful. -
Prefer specific interfaces over
any.func writeTo(w io.Writer)is much better thanfunc writeTo(x any)- the first tells the reader (and the compiler) what's expected. Reach foranyonly when you genuinely accept anything.
Try it:
-
Define
func describe(v any) { fmt.Printf("type=%T value=%v\n", v, v) }. Call it with anint, astring, and yourMoneystruct. The%Tverb prints the dynamic type. -
Note that you can't do
v + 1insidedescribe- even if you pass anint. The compiler doesn't know it's anintonce it's in aanyslot. You'd have to assert (next section).
Type assertions and type switches¶
When you have an interface value and you want to get back to the concrete type underneath, you use a type assertion:
var v any = "hello"
s := v.(string) // type assertion: "treat v as a string"
fmt.Println(s, len(s)) // hello 5
v.(string) reads "give me v's underlying value, asserting it's actually a string." If v really is a string, you get it. If it isn't, the program panics.
The safer comma-ok form doesn't panic:
You've seen this shape before - same pattern as m["key"] returning (value, ok) on maps. The "comma ok" form is everywhere in Go.
For deciding among several types, use a type switch:
func describe(v any) {
switch x := v.(type) {
case nil:
fmt.Println("it's nil")
case int:
fmt.Println("an int:", x*2)
case string:
fmt.Println("a string of length", len(x))
case fmt.Stringer:
fmt.Println("something stringer-able:", x.String())
default:
fmt.Printf("don't know: type=%T value=%v\n", v, v)
}
}
Read the syntax carefully: switch x := v.(type) is special. The .(type) keyword (not a real type - a contextual keyword) is only legal inside a type switch. Each case is a type; inside that case, x has that type and can be used as such. case int: makes x an int; case fmt.Stringer: makes x a fmt.Stringer.
This is how fmt.Println actually dispatches: a type switch that checks for Stringer, error, []byte, and many others in some order.
Try it: 1. Type the describe example. Call it with 42, "hi", your Money, and nil. Each case fires. 2. The order of cases matters when types overlap. Try putting case fmt.Stringer: before case int:. What happens with describe(42)? (Hint: int doesn't have a String() method, so it's still not a Stringer. But other types might match earlier than you expect.) 3. Add case []int: and case map[string]int:. Test with appropriate values. Type switches handle complex types just fine.
Constructors (a convention, not a feature)¶
Go has no special "constructor" keyword. The convention is to write a regular function that returns a value of your type, named NewYourType:
func NewPerson(name string, age int) Person {
return Person{Name: name, Age: age, City: "unknown"}
}
// Use it:
alice := NewPerson("Alice", 30)
This is just a function. The pattern gives you somewhere to put validation, defaults, or setup logic. Look for NewX functions in any Go library - they're everywhere.
Going deeper¶
The basics above are enough to use methods and interfaces effectively. The material below is for when you want the precise model - the rules, the runtime mechanics, the famous gotchas, and the idiomatic patterns mature Go developers use. Drawn from the Go spec, Effective Go, and Russ Cox's writing on the runtime. Links at the end.
Method sets: the precise rule (and why *T satisfies more interfaces than T)¶
You met the rule informally above. The exact statement from the Go spec:
- The method set of
Tis the set of methods declared with receiverT. - The method set of
*Tis the set of methods declared with receiverTor*T.
In other words: pointer types include value methods, but value types do not include pointer methods.
That asymmetry matters for interface satisfaction. Consider:
type Speaker interface { Speak() string }
type Cat struct{ Name string }
func (c *Cat) Speak() string { return c.Name + " says meow" } // pointer receiver
Cat's method set is empty (no methods with receiver Cat). *Cat's method set contains Speak. So:
var s Speaker
s = &Cat{Name: "Whiskers"} // OK - *Cat satisfies Speaker
s = Cat{Name: "Whiskers"} // COMPILE ERROR - Cat does not satisfy Speaker
// (Speak has pointer receiver)
If you had defined Speak with a value receiver (func (c Cat) Speak()...), both forms would work, because the method would be in both Cat's and *Cat's method set. But once a single method on a type uses a pointer receiver, you've effectively decided that only *T values can satisfy interfaces requiring that method.
Practical advice: within a single type, stick to one receiver style for all methods - either all value or all pointer. Mixing causes confusing interface-satisfaction puzzles and inconsistent behavior.
Try it: Define type Counter struct{ Value int } with a value-receiver Get() int and a pointer-receiver Inc(). Try assigning both Counter{} and &Counter{} to a Speaker interface (after adding a Speak method with a value receiver). Notice that &Counter satisfies any interface either method-set holds; bare Counter only satisfies interfaces in its smaller method set.
Method values vs method expressions¶
Two ways to refer to a method without immediately calling it.
A method value is a method bound to a specific receiver:
p := Point{X: 3, Y: 4}
dist := p.DistanceFromOrigin // method value: a function bound to p
fmt.Println(dist()) // 5
dist is a function value of type func() float64. The receiver is captured at the moment of binding. Even if p changes later, dist() still operates on the original p it was bound to (for value receivers; pointer-receiver method values capture the pointer, so they follow mutations to what's behind the pointer).
A method expression is a method as a regular function with the receiver as an explicit first parameter:
dist := Point.DistanceFromOrigin // method expression: a free function
fmt.Println(dist(Point{X: 3, Y: 4})) // 5
dist here has type func(Point) float64 - the receiver is now just a regular first argument.
You'll see both forms occasionally in real code. Method values are common (passing obj.Method to a function that takes a callback); method expressions are rarer (mostly for reflection-style code or generic helpers).
Try it: 1. Method value with a value receiver:
p := Point{X: 3, Y: 4}
d := p.DistanceFromOrigin
p.X = 99
fmt.Println(d()) // still 5 - d captured a copy of p
- Method value with a pointer receiver: define
func (p *Point) Magnitude() float64 { ... }. Bindm := pp.Magnitudewherepp := &Point{X: 3, Y: 4}. Mutatepp.X = 99. Callm(). The result changes - becausemcaptured the pointer.
Embedded types and method promotion¶
Go has no inheritance. It has something subtler - embedding - which mostly does the job without the pitfalls.
type Logger struct {
prefix string
}
func (l *Logger) Log(msg string) {
fmt.Println(l.prefix + ": " + msg)
}
// Job embeds *Logger as an anonymous field. Logger's methods are "promoted"
// to Job - callers can do job.Log() as if Job had defined Log itself.
type Job struct {
*Logger // anonymous (embedded) field
Command string
}
func main() {
j := &Job{
Logger: &Logger{prefix: "Job"},
Command: "build",
}
j.Log("starting") // calls Logger.Log via promotion
// prints: Job: starting
}
The key bits:
*LoggerinsideJobwith no field name is called an anonymous (or embedded) field.- Methods on the embedded type are promoted: callers can do
j.Log(...)exactly as ifLogwere defined onJob. - This is composition, not inheritance.
Jobhas-aLogger; it does not is-aLogger. There's no class hierarchy, no overriding semantics - just method promotion.
You can "override" by defining a method on Job with the same name; that wins over the promoted one. But it's not polymorphism in the Java/C++ sense - the embedded Logger's code only ever calls Logger's methods, never Job's. There's no virtual dispatch.
A common use of embedding: adapter types. The standard library's http.HandlerFunc is the classic:
// A function type with a method - so a plain function can satisfy http.Handler.
type HandlerFunc func(http.ResponseWriter, *http.Request)
func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f(w, r)
}
// Now any function with the right signature can be wrapped:
http.Handle("/hi", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "hi")
}))
This is a recurring Go pattern: define a function type with a method, and you've turned plain functions into things that satisfy interfaces.
Try it: 1. Build the Job/Logger example. Confirm j.Log("starting") works through promotion.
-
Add a method
func (j *Job) Log(msg string)onJobitself that prepends the command name. Notice that nowj.Log(...)calls Job's Log (which can still callj.Logger.Log(...)if it wants). -
Define
type StringSet func(string) bool(a function type). Give it aContains(s string) boolmethod that just callsf(s). Now aStringSetis both a function (callable) and an object satisfying aContainerinterface (you'd define) requiringContains. This dual nature is the trick.
Inside an interface value: iface and the itable¶
Russ Cox wrote the canonical reference for this, and the picture is simple. Every interface value in Go is a two-word struct:
interface value (iface):
┌──────────────┬──────────────┐
│ itab pointer │ data pointer │
└──────┬───────┴──────────────┘
│
▼
itab:
{ interface type info
concrete type info
[func pointer for method 1]
[func pointer for method 2]
... }
itab pointer- points to an interface table. The itab is keyed by the (interface type, concrete type) pair. It holds type metadata and a list of function pointers - one per method the interface requires, pointing at the concrete type's implementation.data pointer- points to the actual value (or the value itself if it's word-sized and the runtime can pack it in).
When you call s.Speak() on an interface value s, the compiler generates code roughly equivalent to:
That's the whole dispatch - one indirect call. No reflection, no method-name lookup, no inheritance walk. The itab does the work once, when the interface value is assigned, then caches.
For the empty interface (any / interface{}), there are no methods to dispatch, so the itab degenerates to just a pointer to the type. The runtime calls this variant eface (for "empty face"). It's used by fmt.Println, type assertions on any, the reflect package - everywhere a value needs to flow with its type but no specific behavior.
Sizes worth knowing:
- An interface value is two words (16 bytes on 64-bit machines), even if the underlying value is one byte.
- Passing an interface is cheap (two-word copy).
- Going from concrete type → interface costs an itab lookup the first time the (interface, concrete-type) pair is seen; cached after that.
- Going from interface → concrete (type assertion) is a runtime check: compare the itab's type info with the target type.
Try it:
import (
"fmt"
"unsafe"
)
type Greeter interface{ Greet() string }
type Person struct{ Name string }
func (p Person) Greet() string { return "Hi, " + p.Name }
func main() {
var g Greeter = Person{Name: "Ada"}
fmt.Println(unsafe.Sizeof(g)) // 16 - two words
fmt.Println(g.Greet()) // Hi, Ada
fmt.Printf("%T\n", g) // main.Person - itab told us the type
}
The %T print is reaching into the itab to recover the concrete type.
The nil interface gotcha (typed nil)¶
The most famous Go pitfall. Read this carefully - most beginners hit it within their first six months.
An interface value is nil if and only if both its itab pointer and its data pointer are nil. The implication:
type MyError struct{ Code int }
func (e *MyError) Error() string { return "boom" }
func mightFail() error {
var e *MyError // e is a typed nil: type=*MyError, value=nil
return e // returned as a non-nil interface!
}
func main() {
err := mightFail()
if err != nil { // TRUE - even though e was nil
fmt.Println("got an error:", err)
}
}
Trace it: e is a nil pointer of type *MyError. When returned as an error, Go boxes it into an interface value with itab pointer → *MyError's itabanddata pointer → nil. The itab is *not* nil; the interface is *not* nil. Theerr != nil` check passes even though there was no actual error to report.
This is the typed-nil gotcha. The fix is to return the untyped nil literal directly when there's no error:
func mightFail() error {
var e *MyError
if everythingFine {
return nil // untyped nil - interface really is nil
}
e = &MyError{Code: 42}
return e
}
Or even more disciplined: avoid declaring an *MyError variable and instead initialize it inline at the return:
func mightFail() error {
if everythingFine {
return nil
}
return &MyError{Code: 42} // no chance of accidentally returning typed nil
}
The bug bites most often when functions return concrete error types and a developer thinks "I'll return the zero value when there's no error." Don't. Return the literal nil.
Try it: 1. Build the buggy version. Confirm the err != nil check fires.
-
Replace with the fixed version. Confirm now
err == nil. -
Use
errors.Is(err, nil)- does it reporttruefor the buggy case? (No - it walks the wrap chain, and a typed-nil error wrapping nothing isn't "equal to nil" by that walk either. This is why nil-checking errors viaerrors.Is(err, nil)doesn't save you. The fix is at the return site.)
"Accept interfaces, return structs" (Postel's Law for Go)¶
A Go proverb most strongly associated with Dave Cheney and the broader Go community:
Accept interfaces, return structs.
It's Postel's Law applied to function signatures - "be conservative in what you do, liberal in what you accept." Expanded:
- Functions should accept the smallest interface that captures what they need. Don't take
*os.Filewhen you only need to write bytes - takeio.Writer. This lets callers pass anything writable: a file, a buffer, a network connection, a test fixture. - Functions should return concrete types. Returning an interface forces callers to use only the methods the interface exposes. Returning the concrete struct lets each caller pick the level of abstraction they want - they can immediately assign to an interface if they prefer.
Example:
// Good: accepts a small interface, returns a concrete type.
func WriteHeader(w io.Writer, name string) (*Header, error) {
_, err := fmt.Fprintf(w, "X-Source: %s\r\n", name)
if err != nil {
return nil, err
}
return &Header{Name: name}, nil
}
// Less good: accepts a concrete type (over-specified), returns an interface (under-specified).
func WriteHeader(w *os.File, name string) (io.ReadCloser, error) { ... }
The trick is: don't define interfaces preemptively in the package that supplies a type. Let interfaces emerge from the consumers. The io.Reader interface is defined in io, not in os (where *os.File lives) - because anyone who wants to consume readable bytes can declare their own io.Reader-shaped need.
You'll see exceptions. Interfaces that abstract a major capability (like error) live with the concept. Constructors that only need to return an abstract behavior (like crypto/cipher.NewCBCEncrypter returning cipher.BlockMode) return an interface. The proverb is a default, not an absolute.
Try it: Write a small function func summarize(name string, body io.Reader) (int, error) that returns the number of bytes in body. Call it with strings.NewReader("hello"), with bytes.NewBuffer([]byte("world")), with an open *os.File. All three work. The function never knew or cared which.
Compile-time satisfaction checks¶
When you've written a type and you want it to satisfy a specific interface, but the type doesn't get used as that interface in your package - there's an idiom to make the compiler check at compile time:
type MyHandler struct{ ... }
func (h *MyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ... }
// Compile-time check: *MyHandler must satisfy http.Handler.
// The variable is named _ so it's discarded.
var _ http.Handler = (*MyHandler)(nil)
The line says: "create a discarded variable of type http.Handler, initialized to a nil *MyHandler." Type-checking forces the compiler to verify *MyHandler satisfies http.Handler. If you later remove or rename ServeHTTP, the compiler complains immediately, instead of at the first downstream caller.
You'll see this idiom at the top of files that export a type intended for interface use. It's a contract-enforcement trick with zero runtime cost (the nil value never gets used).
Try it: In a small file, write a type that almost satisfies io.Writer (e.g., method signature is Write(p []byte) int - missing the error return). Add var _ io.Writer = (*YourType)(nil). The compiler error tells you exactly what's wrong. Without that line, the error would only surface when someone tried to use your type as a writer.
Generics extend interfaces (Go 1.18+)¶
Since Go 1.18, interfaces have an additional role: they can define type constraints for generic functions and types. The constraint syntax adds type elements alongside (or instead of) method signatures:
// A constraint: "any type whose underlying type is int, int32, int64, or float64".
type Number interface {
~int | ~int32 | ~int64 | ~float64
}
// A generic function constrained by Number.
func Sum[T Number](values []T) T {
var sum T
for _, v := range values {
sum += v
}
return sum
}
func main() {
fmt.Println(Sum([]int{1, 2, 3})) // 6
fmt.Println(Sum([]float64{1.5, 2.5})) // 4
}
The ~int means "any type whose underlying type is int" - so a user-defined type Celsius int would also be accepted. The | is a union; the constraint is "any of these types."
Important restriction: interfaces that include type elements (int | float64, ~int, etc.) can only be used as constraints. They can't be the type of a regular variable. The interface concept is split into two roles since 1.18: basic interfaces (methods only, usable as values) and general interfaces (also include type elements, usable only as constraints).
For a deeper treatment, see the Go blog's introduction to generics. The short version: interfaces grew a second job, but most everyday Go is still about basic interfaces and method satisfaction.
Where this material came from¶
- Effective Go - Interfaces - implicit satisfaction philosophy, the
-ernaming convention,Stringer, the empty interface. - Effective Go - Pointers vs values - method receiver rules with the standard library examples.
- Effective Go - Embedding - method promotion via anonymous fields, the
HandlerFuncadapter pattern. - Go Data Structures: Interfaces - Russ Cox (research.swtch.com) - the canonical iface/itab/eface explanation with diagrams.
- Go language spec - Method sets - the precise method-set rules.
- Go language spec - Interface types - type elements, unions, the basic/general interface distinction.
- An Introduction to Generics - Go blog - the Go 1.18 generics design.
- Typed nils in Go 2 - Dave Cheney - the most famous Go pitfall.
- SOLID Go Design - Dave Cheney - "accept interfaces, return structs" in context.
Exercise¶
In a new file shapes.go:
-
Define a struct
Rectanglewith twofloat64fields:WidthandHeight. -
Add two methods on
Rectangle: Area() float64- returnsWidth * Height.-
Perimeter() float64- returns2 * (Width + Height). -
In
main, create aRectanglewith width 5 and height 3. Print its area and perimeter. (Expected: 15 and 16.) -
Write a regular function
LargerOf(a, b Rectangle) Rectanglethat returns whichever rectangle has the bigger area. Test it with two rectangles of different sizes. -
Add a constructor
NewSquare(side float64) Rectanglethat returns aRectanglewith both fields set toside. Test it:NewSquare(4).Area()should be16.
Now extend it with an interface:
-
Define a
Circlestruct with onefloat64fieldRadius. Add methodsArea() float64(returns π·r²; usemath.Pi) andPerimeter() float64(returns 2π·r). -
Define a
Shapeinterface that requires bothArea() float64andPerimeter() float64. -
Write a function
Describe(s Shape)that prints"area=X, perimeter=Y". It should accept any type satisfyingShape- bothRectangleandCirclework. -
Call
Describe(Rectangle{Width: 5, Height: 3}). Then callDescribe(Circle{Radius: 4}). The same function handles both. -
Stretch: add a
String() stringmethod toRectanglethat returns"Rect(WxH)". Nowfmt.Println(r)uses it automatically - becauseRectanglesatisfiesfmt.Stringerimplicitly. -
Stretch: add a compile-time check at the top of the file:
Try removing theArea()method fromCircle. The compiler tells you exactly where the contract broke.
What you might wonder¶
"Why uppercase field names?" In Go, capitalizing a name makes it visible from outside the package (other people's code can see it). Lowercase keeps it private to the current package. You'll meet packages properly in page 11. For now: capital is the safer default.
"What's the difference between a struct and a class?" If you've used a language with classes (Java, Python, C++): a Go struct is similar but smaller. No inheritance. No constructors built in. Methods are attached separately (not inside the type declaration). Go gets away with this minimalism because interfaces (which you met above) handle the cases other languages use inheritance for - and they do it in a more flexible way, because satisfaction is implicit instead of declared.
"Can I have a struct that contains another struct?" Yes. Very common. A Person could contain an Address field of type Address. Field access chains: p.Address.Street.
"What's the zero value of a struct?" If you write var alice Person without initializing fields, every field gets its zero value - 0 for numbers, "" for strings, false for booleans, nil for the things we haven't met yet. Useful: a fresh Person is always valid-looking, not garbage.
Done¶
You can now: - Define your own types with type Name struct { ... } (and on non-struct types too). - Create struct values with the Type{Field: value, ...} literal. - Read and write fields with dot notation. - Pass structs to functions and return them. - Attach methods to types with a receiver. - Choose between value and pointer receivers (modification → pointer). - Define an interface that captures a behavior - and pass any type with the right methods through it. - Use the empty interface (any) when you genuinely accept anything. - Recover a concrete type from an interface value with type assertions or type switches.
You can now model things that have multiple properties and write code that works against contracts rather than against specific types. Combined with what came before, you can build programs that are both data-rich and flexible enough to test, swap implementations, and grow without rewriting.
Next page: how Go handles collections - lists of things, lookups by key. Slices and maps.
Next: Many things at once → 06-slices-and-maps.md