Go Types in Depth

June 15, 2025

Core Types

Strings

var s string = "Hello"

Numbers

var i int = 42
i := 42
var u uint = 100  // unsigned
var f float64 = 3.14 // float

Boolean

var b bool = true

Structs

type User struct {
    Name string
    Age  int
}
 
u := User{Name: "John", Age: 30}
fmt.Println(u.Name)
fmt.Println(u)
// John
// {John 30}
u := User{}
fmt.Println(u)
// (nothing)

Arrays & Slices

Arrays (Fixed Size)

var arr [3]int = [3]int{1, 2, 3}
// or
arr := [3]int{1, 2, 3}

Slices (Dynamic)

var nums []int = []int{1, 2, 3}
// or
nums := []int{1, 2, 3}
nums = append(nums, 4)

Slices come with their own methods, which makes working with them extremely user friendly:

  • append -> add elems
  • copy -> copy from slice A to slice B
  • len -> length
  • cap -> capacity

[!info]
👉 Arrays are rarely used directly in Go — slices are much more common!
It's very easy to convert an array to a slice too, here:

Transformation

It's no secret that you can transform arrays into slices with ease. Let's have a look how:

arr := [3]int{1, 2, 3}
slice := arr[:] // slice now refers to the whole array

So now, variable slice points to the same memory as arr does. So if we change slice, it affects arr too.
Example:

arr := [3]int{1, 2, 3}
slice := arr[:]
 
slice[0] = 100
fmt.Println(arr)
fmt.Println(slice)
/*
[100 2 3]
[100 2 3]
*/

However, it’s important to understand they share memory only as long as possible. For example, if you do:

slice = append(slice, 5)

Go will create a new underlying array for slice (now with 4 elements). After this, arr and slice are no longer synced.

JSON

Struct for marshalling/unmarshalling:

type User struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}
 
u := User{Name: "John", Age: 30}
jsonData, _ := json.Marshal(u)
fmt.Println(string(jsonData))
 
// Unmarshal:
var u2 User
json.Unmarshal(jsonData, &u2)
fmt.Println(u2.Name)

Maps

A map is a built-in Go data type that stores key-value pairs, where each key is unique and maps to a value. Also, maps are reference types (like pointers) – just like in C.

Syntax

var m map[keyType]valueType

Examples

Map

m := map[string]int{
    "apple":  5,
    "banana": 10,
}
fmt.Println(m["apple"])

JSON

import (
    "encoding/json"
    "fmt"
)
 
func main() {
    m := map[string]interface{}{
        "name":  "John",
        "age":   30,
        "likes": []string{"Go", "JSON", "Maps"},
    }
    // Convert map to JSON string
    jsonData, err := json.Marshal(m)
    if err != nil {
        panic(err)
    }
    fmt.Println(string(jsonData))
}

You can also parse JSON into maps:

var m map[string]interface{}
jsonStr := `{"name":"John","age":30}`
 
err := json.Unmarshal([]byte(jsonStr), &m)
if err != nil {
    panic(err)
}
 
fmt.Println(m["name"]) // John
 

[!info]

  • Since JSON values can be strings, numbers, bools, arrays, objects, etc., we use interface{} as the map value type to handle this flexibility.
  • Why []byte? json.Unmarshal() expects raw bytes, not a string. Fortunately, Go allows us to get a binary buffer (raw bytes) with ease using []byte() helper.

Pointers

Pointers in Go work very similarly to pointers in C: you still have & and *, just without the pointer arithmetic, aka str++.

Interfaces

An interface in Go is a type that defines a set of method signatures; any type that implements those methods automatically satisfies the interface.

In other words, If a type has these methods — it is this interface.
An interface unites different types (structs, etc.) that share common behavior (i.e. methods).
You don’t care what the type is — you only care that it can do something (has certain methods).

Let's go over this amazing example:

type Speaker interface { Speak() }
 
type Person struct { Name string }
func (p Person) Speak() { fmt.Println("I'm", p.Name) }
 
type Robot struct { ID int }
func (r Robot) Speak() { fmt.Println("Beep boop, ID:", r.ID) }
 
func Greet(s Speaker) { s.Speak() }
 
func main() {
    p := Person{Name: "John"}
    r := Robot{ID: 42}
 
    Greet(p) 
    Greet(r) 
}

Give yourself some time to read and understand the code above.

We create an interface Speaker, which has a function Speak() inside.
Then we create a Person and a Robot, and both of them can speak.
We also create a Greet() function, which accepts only Speakers.

The beauty of Go is that as soon as we declare that Robot and Person have a Speak() method, they automatically satisfy the Speaker interface (in other words, they get promoted to speakers). Therefore, we can use the Greet() function on both Person and Robot now.

Empty Interface

var anything interface{}
anything = "string"
anything = 123
anything = []string{"a", "b"}

But in modern Go we usually prefer any:

var anything any = 123

Channels

In Go, channels let goroutines talk to each other safely without locks.

Think of channels like pipes:

  • One goroutine writes data in.
  • Another goroutine reads data out.

Basic channel

ch := make(chan int)
 
go func() {
    ch <- 42 // send 42
}()
 
val := <-ch  // receive value
fmt.Println(val) // 42

Closing channels

close(ch)

Signals that no more data will be sent.

Channels block!

  • Send blocks if no one is reading.
  • Receive blocks if nothing is sent yet.
    This is why they synchronize goroutines automatically.

Buffered channels

You can make channels with buffer:

ch := make(chan int, 2) // buffered channel size 2
 
ch <- 1
ch <- 2
// now buffer full, next send would block

Buffered channels don’t block until buffer is full.

So even in the following example, the usage of a buffered channel is a must in order to avoid deadlock error:

func main() {
	ch := make(chan int, 2)
	var wg sync.WaitGroup
	wg.Add(2)
	
	go func() {
		defer wg.Done()
		ch <- 1
		fmt.Println("First goroutine")
	}()
	
	go func() {
		defer wg.Done()
		ch <- 2
		fmt.Println("Second goroutine")
	}()
	
	wg.Wait()
	close(ch)
	for v := range ch {
		fmt.Println(v)
	}
}

Range over channel

Make sure to close the channel before going through it in order to avoid errors!

ch := make(chan int)
 
go func() {
    for i := 0; i < 3; i++ {
        ch <- i
    }
    close(ch)
}()
 
for val := range ch {
    fmt.Println(val)
}

👉 Channels are Go’s way to avoid shared memory problems.
“Don’t communicate by sharing memory; share memory by communicating.”


Declaration & Allocation

[!info]
In Go, each variable is automatically initialized to it's default value: "" for a String, 0 for an int and so on.

[!info]
In Go, it's common not to explicitly define the type of a variable. Instead, we use the := operator to let the compiler infer the type.

Basic

var x int       // zero value 0
var s string    // zero value ""
var a [3]int    // array of 3 ints, zeroed
var p *int      // pointer, zero value nil

Using new()

Allocates zeroed memory for any type and returns a pointer to it. It doesn’t initialize internal structures like slices, maps, or channels. It’s rarely used directly because simpler zero-value declarations or composite literals are preferred.

p := new(int)      // *int pointer to zero int (0)
pArr := new([5]int)// *[5]int pointer to zeroed array

Using make()

Allocates and initializes internal data structures (slices, maps, and channels).

s := make([]int, 5)        // slice with length 5, underlying array allocated
m := make(map[string]int)  // initialized map ready to use
ch := make(chan int)       // initialized channel ready to use

Composite literals

a := [3]int{1, 2, 3}          // array literal
s := []int{1, 2, 3}           // slice literal
m := map[string]int{"a": 1}   // map literal
p := &Person{Name: "John"}    // pointer to struct literal

[!info]

  • Person{Name: "John"} — creates a struct value directly. It's like a full copy of that struct.
    -> Use if you want to pass by value, or store a copy.
  • &Person{Name: "John"} — creates the struct value and then returns a pointer to it.
    -> Use if you want to work with a pointer, just like in C.

Built with love using Next.js and Tailwind