Value semantics and the nil-interface trap
Java passes references by value, and every object is a pointer. Go passes everything by value: structs are copied, arrays are copied, and only pointers, slices, maps, channels, and funcs are descriptors that share underlying data.
This is not a detail. It is where most Java-developer Go bugs come from, together with one specific nil trap.
Copy semantics
package main
import "fmt"
type Config struct{ Name string; Retries int }
func rename(c Config) { c.Name = "changed" } // mutates a copy — caller unaffected
func renamePtr(c *Config) { c.Name = "changed" } // mutates the caller's struct
func main() {
c := Config{Name: "orig", Retries: 3}
rename(c)
fmt.Println(c.Name) // orig
renamePtr(&c)
fmt.Println(c.Name) // changed
// slices share the backing array: this is NOT a copy
xs := []int{1, 2, 3}
ys := xs
ys[0] = 99
fmt.Println(xs[0]) // 99 ← the classic surprise
// maps are reference-like too
m := map[string]int{"a": 1}
n := m
n["a"] = 42
fmt.Println(m["a"]) // 42
}
record Config(String name, int retries) {} // records are value-ish, objects are not
static void rename(Config c) { /* cannot mutate a record; reinventing is not possible */ }
type Config = { name: string; retries: number };
const rename = (c: Config): Config => ({ ...c, name: 'changed' }); // copy via spread
const xs = [1, 2, 3];
const ys = xs; // same reference, same surprise as Go
ys[0] = 99; // xs[0] === 99
The rule of thumb: use pointer receivers on mutable types, and be explicit at call sites about sharing.
append is the sharpest edge — it may return a slice pointing at the same array, or a new one:
package main
import "fmt"
func main() {
base := make([]int, 3, 10) // len 3, cap 10
a := append(base, 1) // fits in cap → shares base's array
b := append(base, 2) // ALSO shares it → b[3] overwrites a[3]
fmt.Println(len(base), cap(base))
fmt.Println(a[3], b[3]) // 2 2 — a[3] changed under you
}
That is why append results must always be assigned, and why passing a subslice to another goroutine needs thought.
The nil interface trap
An interface value is a pair (type, value). It is nil only when both are nil. A nil pointer assigned into an
interface produces a non-nil interface holding a nil pointer — and your err != nil check goes green while a nil
dereference happens further down.
package main
import "fmt"
type Fetcher interface{ Fetch() error }
type Client struct{ url string }
func (c *Client) Fetch() error { return fmt.Errorf("GET %s", c.url) } // nil receiver → panic on c.url
// BUG: returns a non-nil interface wrapping a nil *Client.
func badFetcher() Fetcher {
var c *Client // nil
return c // interface = (type=*Client, value=nil) → != nil
}
func goodFetcher() Fetcher {
return &Client{url: "https://example.com"} // return nil when it genuinely means "nothing"
}
func main() {
if f := badFetcher(); f != nil {
fmt.Println("interface is non-nil →", f)
fmt.Println("but the value inside it is nil →", f == (*Client)(nil))
// f.Fetch() would panic here: nil receiver, c.url dereference.
}
fmt.Println("goodFetcher non-nil →", goodFetcher() != nil)
}
// Java has no interface-pair trap: a null reference is null, and you get an NPE at the call site
// (fast, honest, obvious). Go's version is a silent pass through every nil check first.
interface Fetcher { void fetch(); }
Fetcher f = null;
if (f != null) f.fetch(); // never entered — correct by construction
// undefined/null are not wrapped: the check behaves like Java's
type Fetcher = { fetch(): void };
const f: Fetcher | null = null;
if (f) f.fetch();
The two rules that make this vanish in practice:
- Never return a typed nil through an interface. Return the interface type and
return nilexplicitly, or make the constructor return(Fetcher, error)and let the caller check the error. - Do not declare a
var x *Tand hand it to something that takes an interface. Zero-value structs are usually better:&Client{}is a valid, non-nil pointer with sensible zero fields — that is the Go design idiom you should be reaching for instead of null.
Trap:
==on structs containing slices or maps does not compile. Usereflect.DeepEqualin tests, and write an explicitEqualmethod on domain types when you need it in production code.
Checkpoint
- Run the three programs above. Predict the output first, then check — especially
xs[0]afterys[0] = 99. - Write
func Mutate(s []string)that appends to its argument and show, with alen/capprint, whether the caller sees the change. Explain it to yourself in one sentence; this question appears in real Go interviews. - Reproduce the nil-interface bug in a test, then fix it both ways (return
nilexplicitly; and return a zero-value&Client{}). - Run
go vet ./...on the buggy version — note that vet does not catch it. That is why rule 1 is a habit, not a check.