Go for Developers: Composite Types

Introduction
In Go for Developers: Setup, Variables, and Basic Types we covered the basics: setup, variables, and primitive types and type taxonomy. It's time to learn about composite types - the structures are used to model real data in every Go application.
If you come from JavaScript, you may have encountered class syntax, though JavaScript is not a classical OOP language. Under the hood it uses prototypal inheritance, and many JS developers prefer a functional or mixed style.
Go steps outside OOP paradigm even more: no inheritance, no class keyword, no constructors. Instead, Go relies on Structs for data, Methods for behavior, and Interfaces for polymorphism.
The building blocks for data is a set of composite types: arrays, slices, maps, structs, pointers, functions, and channels. If you're coming from JavaScript, some of these will feel familiar, but the behavior is different. This part is focused on the four data types: arrays, slices, maps and structs.
Arrays
In Go, an array is an ordered sequence of elements of the same type with a fixed length (number of elements). Length is defined in compile time and used to describe arrays. An array type is defined by its elements type and number of elements, so that [3]int and [5]int are two distinct types.
Declaration & Initialization
The syntax to declare an array is [n]T, where n is the length and T is the element type. The length must be a non-negative constant, it cannot be a variable.
go1var scores [3]int // [0 0 0] — zero values2grades := [3]string{"A", "B", "C"}3coords := [...]float64{1.0, 2.5, 3.7} // compiler counts the elements
If you omit explicit values during initialization, the array is initialized to the zero value of its element type: 0 for integers, "" for strings, false for booleans, and so on.
The [...] syntax is shorthand that tells the compiler to set the length based on the number of elements. It's syntactic sugar, the resulting type still has a fixed length. Array elements are accessed using their index, starting from zero: a[0], a[1], and so on.
Core Operations
Go provides a built-in function for arrays: len. It returns the number of elements in the array.
go1a := [5]int{1, 2, 3, 4, 5}2fmt.Println(len(a)) // 5
Behavior & Memory
Arrays in Go are values. When you assign an array to a new variable or pass it to a function, the entire array is copied. This prevents accidental side effects. You can pass an array to a function and be certain that the function won't mess up your original data unless you explicitly allow it.
go1func main() {2 original := [3]int{1, 2, 3}3 duplicate := original // A complete copy is made here45 duplicate[0] = 9967 fmt.Println(original) // [1 2 3] (Unchanged)8 fmt.Println(duplicate) // [99 2 3]9}
But that copy has a real cost. Passing an array of 10 million integers to a function forces Go to allocate a brand-new block of memory and copy every element into it just to make a function call, every single time.
In practice, developers rarely work with raw arrays in Go. They exist primarily as the foundation for slices.
Slices
A slice is a small wrapper around an array. It contains a pointer to the data, a length, and a capacity. Length is the current number of elements. Capacity is the room available before Go must allocate a new underlying array. Pointer is internal reference to the first element of the slice. This three-field structure is sometimes called the slice header. You don't interact with these fields directly, built-in functions len() and cap() expose length and capacity, and the pointer is managed by the runtime.
Zero value
Before discussing slice declaration, we need to look closer at the zero value mentioned in the previous article. For pointers, slices, maps, channels, functions, and interfaces, the zero value is nil, meaning the variable has been declared but has no underlying data or implementation assigned to it. Note that arrays cannot be nil.
An empty slice and nil slice are not the same. The difference between a nil slice and an empty slice matters in two situations: a check s == nil returns true only for the nil slice, and JSON marshaling produces null for a nil slice and [] for an empty one. In all other respects — len, append, range — they behave identically.
Declaration & Initialization
In a declaration a slice uses empty brackets: []int is a slice and [3]int is an array.
There are four distinct ways to declare a slice.
vardeclaration declares anilslice. The pointer isnil, length is0, and capacity is0. No underlying array is allocated.
go1var s []int
- Empty literal declares an empty slice. Length and capacity are both
0. The pointer points to a valid memory address, a special internal variable calledruntime.zerobase. This is a single shared memory address used for all zero-size allocations. The slice is notnil. No underlying array is allocated.
go1s := []int{}
- Literal with values declares a slice where length and capacity are both
3. The pointer points to the first element of the underlying array, which is allocated with those three values.
go1// length is 3, capacity is 3, elements are [1 2 3]23s := []int{1, 2, 3}
makeallocates an underlying array with length3and capacity3and returns a slice pointing to its first element.
go1// length is 3, capacity is 3, elements are [0 0 0]23s := make([]int, 3)
Core Operations
make([]T, length, capacity) allocates a new array and returns a slice referring to it. The first argument is the type, the second is the length, and the optional third is the capacity. All elements are initialized to the zero value of the element type. When capacity is omitted, it equals the length.
In the example below the make call allocates an array with capacity 5 and returns a slice pointing to the first element. The first three elements are accessible and initialized to the zero value [0 0 0]. The remaining two slots exist in the underlying array but are outside the slice's current length.
go1// length is 3, capacity is 5, elements [0 0 0], underlying array has room for 523s := make([]int, 3, 5)
len(s) returns the number of elements the slice currently holds.
cap(s) returns the number of elements the underlying array can accommodate starting from the first element of the slice.
go1s := make([]int, 3, 5)2sub := s[1:3]34fmt.Println(cap(sub)) // 4, not 5
append() adds elements to the end of a slice and returns a new slice. If the underlying array has sufficient capacity, append extends the length within the same array. If it does not, Go allocates a new, larger array, copies the existing elements, and appends the new ones. In either case the original variable is not updated in place.
go1s := make([]int, 3, 5)2s = append(s, 4) // fits within capacity, no new allocation3s = append(s, 5, 6) // exceeds capacity, new array allocated
copy(dst, src []T) copies elements from a source slice into a destination slice. It returns the number of elements copied, min(len(dst), len(src)). It always operates on existing elements, it does not extend the destination slice.
go1src := []int{1, 2, 3}2dst := make([]int, 2)34n := copy(dst, src)56fmt.Println(dst) // [1 2]7fmt.Println(n) // 2
Slicing is an operation on an array or string that creates a new slice. It can be performed by using the expression s[low:high], where low is the index of the first element included and high is the index of the first element excluded. Both are optional and default to 0 and len(s) respectively.
go1s := []int{10, 20, 30, 40, 50}2sub := s[1:4] // [20, 30, 40]
This does not copy the data. The sub is a slice, it shares the same underlying array and points to elements at indices 1, 2, and 3 from the original array without copying data, so modifying sub[0] changes s[1].
Behavior & Memory
When a slice is passed to a function, the slice header - the pointer, length, and capacity - is copied by value. The underlying array is not copied.
go1func setFirst(s []int) {2 s[0] = 993}45a := []int{1, 2, 3}6setFirst(a)78fmt.Println(a[0]) // 99 — the underlying array was mutated
Because the header is copied, operations that replace the header, such as append, are not visible to the caller.
go1func addElement(s []int) {2 s = append(s, 100)3}45a := []int{1, 2, 3}6addElement(a)78fmt.Println(len(a)) // 3 — caller's slice is unchanged
To allow a function to modify the caller's slice variable itself, pass a pointer to the slice: func addElement(s *[]int). In practice, the more common pattern is to return the modified slice and reassign it at the call site, consistent with how append is used throughout Go.
Maps
Maps are a key-value store implemented as hash tables. They are unordered and must be initialized before any values are set. Under the hood, a map variable is just a pointer to an internal map structure - hmap.
Declaration & Initialization
The syntax to declare a map is map[KeyType]ValueType, where KeyType must be comparable and ValueType can be any type.
What does comparable mean? The type must support == and !=: slices, maps, and functions cannot be used as keys for this reason.
vardeclaration declares anilmap. The map is not initialized and cannot be written to. Reading from a nil map is safe and returns the zero value for the value type.
go1var lookup map[string]int
- Map literal declares and initializes a map with values. The map is ready to use immediately
go1config := map[string]string{2 "host": "localhost",3 "port": "8080",4}
makeallocates and initializes an empty map, setting up the hash table in memory, and returns a map value, which is a pointer to that hash table. The map is ready to use immediately.
go1scores := make(map[string]int)2scores["alice"] = 953scores["bob"] = 87
Writing to a nil map causes a runtime panic (a program termination that Go uses for unrecoverable errors, will be covered in later in the series). It's a good habit to always use make or a literal when declaring maps you intend to write to.
Core Operations
In Go, keys are runtime values whose existence must be checked explicitly with the comma-ok idiom.
go1val, ok := scores["carol"]23if !ok {4 fmt.Println("key not found")5}
What is the comma-ok idiom? In Go, some operations can return two values: the result, and a boolean that tells you whether the result is valid. It's conventionally called the comma-ok idiom. The boolean is conventionally named ok. The "comma" refers to the val, ok := syntax.
You see it in three places: map lookups (key exists?), type assertions (is this the right type?), and channel receives (is the channel still open?). The pattern is always the same shape: result on the left, ok on the right.
Use delete method to delete keys in map:
go1delete(scores, "bob")
Note, that Go doesn't complain if you try to delete a key that was never there.
Behavior & Memory
In the Go runtime, a map is represented by a struct called hmap - header map. It contains: count, buckets, hash0, B. count is a number of elements currently stored. buckets is a pointer to an array of bmap structs (the buckets) where the actual key-value data lives. hash0 is a hash seed to prevent hash flooding attacks. B is a base-2 logarithm of the bucket count (the actual number of buckets is 2^B).
When you call make, the runtime allocates the hmap struct and returns a pointer to it - *hmap. That pointer is what your map variable holds, not a copy of the data. When you pass a map to a function, you are passing a copy of that pointer, which means modifications inside the function point to the exact same hmap and are visible to the caller. This is worth contrasting with slices where passing a slice copies the header (pointer + length + capacity), which makes caller unaware of changes in some cases.
Structs
A struct is a sequence of named elements. The elements are called fields, each of them has a name and a type. Structs are how Go models structured data. There are no classes in Go, unlike other languages. In Go structs are the only way to group heterogeneous data under a named concept.
Structs in Go:
- Group heterogeneous fields (different types together)
- Can have methods attached to them
- Can implement interfaces
- Represent a named domain concept
Declaration & Initialization
Note, that this article covers structs declared with type literals only, structs are often should be declared with defined types, and structs declared with type literals are useful for data that are used only once. I will cover defined types later.
go1var u struct {2 Name string3 Age int4}56// Then assign fields by name7u.Name = "Alice"8u.Age = 30
You can create a struct with named fields (preferred) or positional fields. With named initialization, each value is explicitly tied to a field by name, order doesn't matter:
go1u1 := struct {2 Name string3 Email string4 Age int5}{6 Name: "Alice",7 Email: "[email protected]",8 Age: 30,9}
If you later add a field Phone string between Email and Age, this still works correctly, every field is assigned by name, order doesn't matter.
With positional form, values are assigned by their position in the struct definition:
go
This form breaks silently if you later add or reorder fields:
go1// Someone swaps Name and Email in the definition2u2 := struct {3 Email string // moved up4 Name string5 Age int6}{"Bob", "[email protected]", 25}7// ^Email gets "Bob" ^Name gets "[email protected]" ^Age
Now Email holds "Bob" and Name holds "[email protected]": both are string so the compiler sees no issue. The bug is completely silent.
Behavior & Memory
Like arrays, structs are passed by value and they are fixed-size. Assigning a struct to a new variable creates a full copy. When you need to share or mutate a struct across functions, you'll use a pointer.
Unlike maps, in structs you can't check fields at runtime (no comma-ok idiom for struct fields) because they are static and validated at compile time.
In Go, everything is passed by value, but the size of the value varies by type. Arrays and structs are copied entirely when passed or assigned. Slices and maps copy their headers or pointers by value, and share the underlying data they point to.
In the next part we'll cover defined type, pointers and functions — where Go's value semantics start to become a design choice.
Next article: Go for Developers: Named Types, Pointers and Functions
Also published on Medium.