gopherpath
0%

Value semantics and the nil-interface trap

40 min · lesson 2 of 2 · #types · #pointers · #nil

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

Go
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
}
Java
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 */ }
TypeScript
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:

Go
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.

Go
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
// 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
TypeScript
// 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:

  1. Never return a typed nil through an interface. Return the interface type and return nil explicitly, or make the constructor return (Fetcher, error) and let the caller check the error.
  2. Do not declare a var x *T and 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. Use reflect.DeepEqual in tests, and write an explicit Equal method on domain types when you need it in production code.

Checkpoint

  1. Run the three programs above. Predict the output first, then check — especially xs[0] after ys[0] = 99.
  2. Write func Mutate(s []string) that appends to its argument and show, with a len/cap print, whether the caller sees the change. Explain it to yourself in one sentence; this question appears in real Go interviews.
  3. Reproduce the nil-interface bug in a test, then fix it both ways (return nil explicitly; and return a zero-value &Client{}).
  4. 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.
stored in this browser only