Go is often called an easy language to read, but when I was starting out, it took me a while to work through it.
Pointers confused me the most.
var u User
var p *User = &u
In TypeScript, for example, you rarely have to think about an explicit pointer type when working with objects. Objects are handled through references, but there is nothing like Go’s * to mark a pointer type. So at first, Go’s * and & looked like extra noise to me.
Once I sorted out a few ways of reading them, though, Go code became much easier to follow.
Read * by where it appears
The easiest approach is to read * mechanically, based on its context.
In type position, *User means “a pointer to User.”
var p *User
In expression position, *p stands for the value that p points to. This is called dereferencing.
u := *p
It is not limited to reading: you can also use it as an assignment target, as in *p = User{Name: "new"}. Either way, if p is nil, the dereference itself panics.
And when it shows up as a binary operator, it is just multiplication.
x := a * b
In everyday reading, this much classification is enough.
*T → pointer type to T
*p → dereference of p
a * b → multiplication
When you actually want a pointer
It also helps to sort out why * shows up in the first place.
A struct in Go is a value type, so passing one to a function copies it. That means the function below has no effect on the caller’s value, because what gets modified is a copy.
func Rename(u User) {
u.Name = "new"
}
Take a pointer instead, and the caller’s User does change.
func Rename(u *User) {
u.Name = "new"
}
In other words, when a * is there, it usually carries the intent of “I want to modify a value that lives somewhere else, not a copy of it.” The * on the pointer receivers that show up later is there for basically the same reason.
Is TypeScript “always pointers”?
Seen this way, TypeScript looks like it always passes pointers. For objects, that understanding is roughly correct.
function rename(u: User) {
u.name = "new"; // visible to the caller
}
To do the same thing in Go, you need to take a *User.
That said, passing a reference is not the same as passing the caller’s variable itself.
The u inside the function is a separate variable from the caller’s u, and the two just happen to point at the same object.
So assigning a different object to u never reaches the caller.
function replace(u: User) {
u = { name: "new" }; // not visible to the caller
}
Drawn out, it looks like this.
right after the call
caller's u ────┐
function's u ──┴──→ { name: "a" }
after u = { name: "new" } runs
caller's u ───────→ { name: "a" }
function's u ─────→ { name: "new" }
The earlier u.name = "new" reached the caller because it rewrote the object at the end of the arrow.
u = { name: "new" } only repoints the arrow held by the function’s u.
What the function receives is a copy of the reference, not the caller’s variable itself.
The same holds for a Go pointer.
Even when you take a *User, what you receive is a copy of the pointer, so repointing u inside the function leaves the caller unchanged.
func Replace(u *User) {
u = &User{Name: "new"} // not visible to the caller
}
What does reach the caller in Go is dereferencing the pointer and overwriting what it points at.
func Replace(u *User) {
*u = User{Name: "new"} // visible to the caller
}
*u = ... does not repoint u at a different User.
It overwrites the User at the end of the arrow, field by field.
The TypeScript counterpart is u.name = "new", not u = { name: "new" }.
Primitives such as number and string are not references either.
Their values are copied, so they behave like a Go struct passed by value.
To summarize, passing a value to a function corresponds like this.
TypeScript object → Go's *T : writes reach the caller
TypeScript primitive → Go's T : writes do not reach the caller
Go’s T, a struct passed by value, has no counterpart in TypeScript.
To pass a copy, you have to build one yourself with { ...u }.
That is a shallow copy, though, so nested objects stay shared with the original.
TypeScript gives you no choice here, so there is nothing to write down; Go lets you choose, so the choice shows up as *. Framing the difference that way made it easier to keep straight.
A pointer really does hold an address
I have been writing “points to” so far, but the content of a pointer is a concrete number. It holds an address in memory.
u := User{Name: "a"}
p := &u
fmt.Printf("%p\n", p) // varies per run, something like 0x1b78016a2020
A nil pointer, on the other hand, looks like this.
var q *User
fmt.Printf("%p\n", q) // 0x0
Put the two side by side, and the only difference is the number sitting in the address slot.
p (8 bytes on a 64-bit platform)
┌────────────────────┐
│ 0x00001b78016a2020 │ → the address where User{Name: "a"} lives
└────────────────────┘
q (8 bytes as well)
┌────────────────────┐
│ 0x0000000000000000 │ → 0, meaning it points nowhere
└────────────────────┘
The thing to watch out for is that no special nil value sits in that address slot. The slot only ever holds a number. When that number is 0, Go source code spells the state as nil.
how you write it in Go source → nil
what memory actually holds → 0
what %p prints → 0x0
These are the same state named at different layers. Reading it as “the address is 0, and Go calls that nil” rather than “the address is nil” keeps things from getting tangled.
The difference between p and q follows from the same picture.
p == nil → false : the address slot is not 0
q == nil → true : the address slot is 0
*p → OK : a real User sits at that address
*q → panic : nothing is placed at address 0
The reason 0 works as the marker for “points nowhere” is that the area around address 0 is reserved so no valid data is ever placed there. That is why dereferencing nil reliably panics. You are trying to look at a place that nothing points to.
That said, you almost never handle the number itself. Ordinary Go has no pointer arithmetic, so you cannot write something like p + 1 (the unsafe package makes it possible, but it does not show up in the code you write day to day). “There is an address in it, and the real value sits at the other end” is as far as you need to take it.
Don’t think of an interface itself as a pointer
The following code was also hard for me to read at first.
p := &User{}
var x any = p
The type of p is *User, but the static type of x is any. any is an alias for the empty interface.
Separating the static type from the dynamic type makes this easier to understand.
x
├─ static type: any
├─ dynamic type: *User
└─ dynamic value: p
So the static type of x itself is not a pointer.
x is an interface value, and it holds a value of type *User inside it.
You can see the same difference in the memory layout from the previous section.
var p *User
var x any
fmt.Println(unsafe.Sizeof(p)) // 8
fmt.Println(unsafe.Sizeof(x)) // 16
A pointer is 8 bytes, the size of one address, while an interface value is 16. It carries two slots: the dynamic type and the dynamic value.
p (pointer)
┌─────────┐
│ address │
└─────────┘
x (interface value)
┌──────────────┬───────────────┐
│ dynamic type │ dynamic value │
│ *User │ address │
└──────────────┴───────────────┘
So x is not simply a variable holding a pointer. It keeps “which type is this value” in a slot of its own, and that is what it means for the static type not to be a pointer.
unsafe.Sizeof is here only to inspect the layout; you do not need it in everyday code.
TypeScript’s interface looks similar on the surface, but a TypeScript interface declaration normally leaves no type information at runtime. A Go interface value, by contrast, carries a concrete dynamic type at runtime.
nil is not a word reserved for pointers
I have been treating nil as a pointer topic so far, but it is not limited to pointers. Every value below compares equal to nil.
var p *User // nil pointer
var s []int // nil slice
var m map[string]int // nil map
var f func() // nil func value
var i any // nil interface
fmt.Println(p == nil, s == nil, m == nil, f == nil, i == nil) // all true
nil is a single spelling for the zero value of all of these types.
nil ─┬─ nil pointer
├─ nil slice
├─ nil map
├─ nil channel
├─ nil func value
└─ nil interface
What you can do with a nil differs by type, though. A nil slice has a length, and you can append to it.
var s []int
fmt.Println(len(s)) // 0
s = append(s, 1) // appending works fine
A nil pointer cannot be dereferenced at all. The memory layout differs by type as well, so “if it is nil, the address is 0” does not generalize. The address is 0 only when the thing inside is a pointer.
nil also has no type of its own.
x := nil // compile error: use of untyped nil in assignment
There is no way to tell which type’s zero value this should be, so it does not compile. Only once the type is fixed, as in var p *User = nil, does it mean something specific.
The relationship comes out like this.
nil → an identifier with no type; the spelling of a zero value, and an umbrella term
nil pointer → a concrete value of type *T whose address slot holds 0
So “nil” and “nil pointer” are not the same thing: the latter is one instance of the former. That distinction feeds directly into the x == nil discussion coming up next.
nil makes sense with the same model
Take this code, for example.
var p *User = nil
var x any = p
fmt.Println(p == nil) // true
fmt.Println(x == nil) // false
Both lines look like they compare against the same nil, yet the results differ.
First, conceptually, x is in this state.
dynamic type = *User
dynamic value = nil
This nil is a nil pointer value of type *User.
This is where the umbrella nature of nil from the previous section comes in. Since nil has no type of its own, which type of nil it stands for is decided by whatever it is compared against.
In p == nil, p has type *User, so the nil is compared as a nil pointer value of *User. That is why the result is true.
In x == nil, on the other hand, x is an interface, so this nil is treated as an interface value carrying neither a type nor a value — the zero value of an interface. Since x carries *User as its dynamic type, the two are not equal, so the result is false.
In terms of the two slots from earlier, the state is this.
x : dynamic type = *User (non-zero), dynamic value = 0
nil : dynamic type = (none) (zero) , dynamic value = 0
Both value slots hold 0; the type slots differ. An interface equals the zero value only when both slots are empty, so x == nil is false. x holding a nil pointer and x itself being empty are two different things, and this pair of slots is where that difference lives.
Make the other operand an explicit nil pointer value of type *User, and the result changes.
fmt.Println(x == (*User)(nil)) // true
Here the operand has type *User. The dynamic type of x is also *User, and the value it stores is a nil pointer as well, so the two are judged equal.
When an interface is compared against a non-interface value, it helps to picture the non-interface operand being converted to that interface type first and only then compared. The spec puts it as checking whether the dynamic type of x is identical to the other type and its dynamic value is equal, but the outcome is the same. Lining up the converted forms shows the difference.
x : dynamic type = *User , dynamic value = nil ← compared against
nil : dynamic type = (none), dynamic value = (none) ← types differ, so false
(*User)(nil) : dynamic type = *User , dynamic value = nil ← identical, so true
Note that (*User)(nil) itself is not an interface — it is a nil pointer value of type *User. It takes the shape in the third row only because the comparison converts it.
Interface comparison is not always safe, either. If the dynamic type is one that cannot be compared — a slice, a map, or a func — the code still compiles, but the comparison panics at run time.
var a any = []int{1}
var b any = []int{1}
fmt.Println(a == b) // panic: comparing uncomparable type []int
Comparing against nil never panics, because differing types settle the result before any value is looked at. It is only when comparing two interface values that the type inside matters.
Back to the main thread: nothing inside x changed. What changed is the type of the operand it is compared against.
It helps to think of x as a box labeled *User whose contents are nil. Holding nil is not the same thing as the box itself holding nothing at all.
That split between the box and its contents shows up in wording as much as in behavior. I have been writing that the contents of x are nil, but out loud it is tempting to say “x is a nil pointer.” That phrasing is exactly what makes x == nil returning false hard to explain.
Can you call x a nil pointer?
Strictly speaking, no. x itself is not a nil pointer; the nil pointer is what sits inside the box.
var p *User = nil
var x any = p
Here is the state of each one.
p:
type = *User
value = nil
→ p is a nil pointer
x:
type = any
dynamic type = *User
dynamic value = nil pointer
→ x is an interface value
So x is an interface holding a nil pointer. p, which is a nil pointer, and x, which stores one, are different kinds of value to begin with.
The difference from the previous section,
p == nil // true
x == nil // false
reads naturally from there. p is a nil pointer, so comparing it against a nil pointer matches. x is an interface value, so it gets compared against the zero value of an interface, and the *User in its type slot keeps the two apart. Rather than recalling what is inside x at every comparison, it is enough to file p and x as different things.
Take the contents out of the box, though, and you do get a nil pointer. Taking them out is what a type assertion does.
q := x.(*User)
fmt.Println(q == nil) // true
Laid out together:
p = a nil pointer
x = an interface storing a nil pointer
q = the nil pointer taken back out of x
q is out of the box, so like p it is a nil pointer of type *User, which is why q == nil is true. The same nil pointer is treated as an interface value while it sits in the box, and as a pointer once it comes out.
So the accurate phrasing is not “x is a nil pointer” but “the dynamic value of x is a nil pointer.” It sounds like a matter of wording, but not calling pointer types and interfaces by the same name is a direct extension of the readings from the start: *T is a pointer to T, and an interface is a box with contents.
Pointer receivers and pointer fields are different things
Code like this threw me off too.
type StripePayment struct {
client *stripe.Client
}
func (p *StripePayment) Charge(amount int) error {
// charge processing
return nil
}
At a glance, it looks like pointers stacked on top of pointers.
But the two * marks are separate matters.
client *stripe.Client
This means:
StripePaymenthas a pointer tostripe.Clientas a field
And this:
func (p *StripePayment) Charge(...)
means:
the receiver of
Chargeis a pointer toStripePayment
Drawn out, it is simple.
p
│
▼
StripePayment
│
└─ client
│
▼
stripe.Client
Writing this in TypeScript:
class StripePayment {
constructor(private client: StripeClient) {}
}
gives you a conceptually similar reference relationship.
The client field in TypeScript also holds a reference to an object, but it is not the same thing as a Go pointer. Go states the pointer type explicitly, as in *stripe.Client, and that pointer value can be nil. With that difference in mind, treating * as one of the clues for reading reference relationships made things click for me.
p.client is not a pointer to a pointer
For example, in:
func (p *StripePayment) Charge(amount int) error {
p.client.DoSomething() // a made-up method for illustration
return nil
}
the types are:
p : *StripePayment
p.client : *stripe.Client
Just because p is a pointer does not make p.client a **stripe.Client.
Go treats p.client as conceptually:
(*p).client
It simply looks at the StripePayment that p points to and takes the client field out of it.
Note that if p is nil, evaluating (*p).client panics.
Read this interface as a contract
Say you have:
type Payment interface {
Charge(amount int) error
}
and somewhere it is called as:
payment.Charge(order.Total)
At this point, you do not need to think about whether SQL runs inside or the Stripe API gets called.
Going by the naming in this example, it is enough to start by reading it as the design intent:
hand an amount to
payment, ask it to charge, and receive anerroras the result
The interface declaration alone does not guarantee that a charge actually happens, or under what conditions an error comes back.
You check the concrete implementation only when you need to.
call site
↓
what it does
interface
↓
what contract it states
concrete implementation
↓
how it is carried out
Reading in this order makes it harder to get dragged into details.
Don’t infer types from := alone
For example, from:
user, err := repo.Find(userID)
alone, you cannot decide that user is probably a *User.
You have to check whether Find is:
Find(id int) (*User, error)
or:
Find(id int) (User, error)
Go is not a language where a single line tells you everything.
Once you follow it back to the declaration, though, the type is clearly written out:
Find(id int) (*User, error)
I think this is one of the things that makes Go readable.
What Go’s readability actually means
TypeScript carries plenty of type information too, so explaining Go’s advantage as simply “the types are explicit” is not quite right.
With Go, it feels more like the language reduces the guesswork needed to interpret code, along lines such as:
- distinguishing values from pointers
- expressing interfaces as small contracts of the operations you need
- stating
errorexplicitly as a return value - keeping the number of language features and ways to write things relatively small
At the same time, not everything is spelled out in the notation. Type inference through :=, or the implicit address-of when you call a pointer receiver method on an addressable value, are examples of that.
So when reading Go, instead of always asking:
what is really happening under the hood
reading at roughly this granularity seems to work well.
*T
→ a pointer to T
interface
→ a contract of the operations you need (for basic interfaces like this one)
*T inside a struct
→ a field holding a pointer to T
pointer receiver
→ a method whose receiver is `*T`
It is the same as not expanding TypeScript object references down to their memory layout every time.
Go’s “readability” does not mean each individual line is more concise than other languages. I think it means that once you need a piece of information, you can confirm it fairly directly from a type or interface declaration.
Since I switched to this view, Go’s * has gradually stopped looking like a hard-to-read symbol and started reading as a symbol that spells out the information I need.