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 means “get the value that p points to.” Note that if p is nil, the dereference itself panics.
u := *p
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.
It is not quite identical, though. What TypeScript passes is a copy of the reference, so reassigning the variable itself does not reach the caller.
function replace(u: User) {
u = { name: "new" }; // not visible to the caller
}
With a Go pointer, you can write this as well.
func Replace(u *User) {
*u = User{Name: "new"} // visible to the caller
}
Primitives such as number and string are not references either.
To summarize, the correspondence looks like this.
TypeScript object → close to Go's *T
TypeScript primitive → close to Go's T
Go's T (struct passed by value) → closest to passing { ...u } in TypeScript
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
nil, on the other hand, is the state of pointing nowhere.
var q *User
fmt.Printf("%p\n", q) // 0x0
With that picture, it feels natural that dereferencing nil panics. You are trying to look at a place that nothing points to.
That said, you almost never handle the number itself. Go has no pointer arithmetic, so you cannot write something like p + 1. “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
└─ 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.
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 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.
What matters here is that nil in Go 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.
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, the non-interface operand is first converted to that interface type, and only then are the two compared. 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.
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.
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.