Slices and Pointers in Go: What Actually Surprised Me Coming From TypeScript
Slice headers, nil comparisons, and pointer fields for optional values: three places Go's syntax looks like TypeScript but the semantics don't match.
Coming from TypeScript, arrays and objects are reference types. Pass one into a function, mutate it, and the caller sees the change. Go’s slices look the same on the surface, right up until you call append inside a function and nothing happens outside it.
A Slice Is a Header, and the Header Gets Copied
A slice is a small struct under the hood: a pointer to a backing array, a length, and a capacity. When you pass a slice to a function, Go copies those three fields. The backing array itself is not copied, so mutating an existing element is visible to the caller. Changing the length is not, because the length only exists in the copy the function is holding.
func double(nums []int) { for i := range nums { nums[i] *= 2 }}
func addOne(nums []int) { nums = append(nums, 1)}
func main() { nums := []int{1, 2, 3}
double(nums) fmt.Println(nums) // [2 4 6] — same backing array, mutated in place
addOne(nums) fmt.Println(nums) // still [2 4 6] — addOne only updated its own copy of the header}This is why s = append(s, x) is everywhere in Go code and isn’t optional style. append may or may not reallocate, and either way the only place that knows about the new length is the return value.
nil Doesn’t Map to undefined the Way I Expected
A nil slice is still a usable slice. len(s) is 0, ranging over it does nothing, and appending to it works fine — Go allocates a backing array the first time it’s needed. The one thing you can’t do is compare two slices to each other; s1 == s2 is a compile error no matter what’s in them. The only legal comparison is against nil.
var s []intfmt.Println(s == nil) // truefmt.Println(len(s)) // 0
s = append(s, 1)fmt.Println(s) // [1]In practice this means I stopped writing “is this nil or empty” checks. len(s) == 0 covers both cases, so the nil-vs-empty distinction that matters in JS (arr == null vs arr.length === 0) mostly disappears.
make() Gives You a Capacity Budget Up Front
new Array(n) in JS sets a length, full of holes. make([]int, 0, n) sets a length of zero but reserves capacity for n elements, so appending up to that many won’t trigger a reallocation. If I know roughly how many items are coming, I preallocate:
ids := make([]int, 0, len(raw))for _, r := range raw { ids = append(ids, parse(r))}If I don’t know the size ahead of time, I just start from a plain var ids []int and let it grow. Guessing a capacity that’s too small buys nothing.
Pointers Show Up Where TypeScript Uses | undefined
The pointer use case that actually changed how I write structs is optional fields. In TypeScript, name?: string distinguishes “not provided” from "" for free. In Go, the zero value of string is "", so a plain string field can’t tell you whether the caller sent an empty string or nothing at all. The fix is a pointer field:
type UpdateUserRequest struct { Name *string `json:"name,omitempty"` Email *string `json:"email,omitempty"`}You can’t take the address of a literal (&"Denys" doesn’t compile), so building one of these means routing the value through a variable or a small generic helper:
func ptr[T any](v T) *T { return &v}
req := UpdateUserRequest{Name: ptr("Denys")}It’s a one-line helper, but the first time you need it and don’t know it’s a common pattern, it’s a strange wall to hit for something TypeScript gives you with a question mark.
Takeaway
Treat a Go slice as a value type with a pointer hidden inside it: mutate elements freely, but always keep whatever append hands back. Reach for an actual pointer field only when you need to say “this might not be here,” not as a general substitute for reference semantics.